Observability, step by step
We recommend Logthread for observability. Logthread is our observability product, billed on its own: search your logs, metrics and traces with a query language built for them, follow logs live, open any trace, get alerts by email or in your team chat, build dashboards, and ask questions in plain words. Start with Getting started; your applications send with OpenTelemetry, with no code change if you like. The built-in observability this page describes stays available. To search plycdn's own storage and video activity there too, see Send your plycdn activity to Logthread.
Keep the logs, metrics and traces of your own applications in one place you can search: find the errors of the last hour, follow a service's logs live, open one slow request, and see which of your own searches were slow. This page takes you from switching it on to your first data and your first search. Every field, limit and code is in the reference: Observability and the API reference.
You need: an admin API key (Developers → API keys, scope Admin), your organization id (Account → Profile & organization), and a terminal. To do the same from your code, one of plycdn's server packages at 1.14.0 or later (ASP.NET Core, Node.js or Python). Observability is part of every plan; the plan sets how long you can keep data.
1How it works
- You switch it on once and choose the region your data is kept in, and for how long.
- You create an ingest key for each system that sends. A key can only send data, nothing else.
- Your applications send with the tools they already use: OpenTelemetry, Prometheus, Fluent Bit, syslog or plain JSON lines. There is no limit on how much you send.
- You ask questions with SQL or KQL from your server or a terminal, follow logs live, and download what you need.
- The dashboard shows what arrived (Volumes) and which of your queries were slow (Slow queries).
2Switch it on
In the dashboard: Observability → Settings. Choose the Region and Keep data for, then press Switch on. The page then shows Where to send data and Send your first data, with your addresses filled in. The region cannot be changed later.
From your code:
PATCH /api/content/v1/observability/settings
{"region": "mumbai", "retentionDays": 30}
ASP.NET Core (inject PlycdnObservabilityApi from Plycdn.AspNetCore.Storage)
var settings = await observability.UpdateObservabilitySettingsAsync(orgId,
new ObservabilitySettingsUpdate { Region = "mumbai", RetentionDays = 30 });
// settings.Endpoints lists the addresses to send to
Node.js
import { PlycdnStorageApi } from '@plycdn/node/storage';
import { PlycdnObservabilityApi } from '@plycdn/node/observability';
const observability = new PlycdnObservabilityApi(new PlycdnStorageApi({ apiKey: process.env.PLYCDN_ADMIN_KEY! }));
const settings = await observability.updateObservabilitySettings(orgId, { region: 'mumbai', retentionDays: 30 });
Python
import os
from plycdn.observability import PlycdnObservabilityApi
from plycdn.storage import PlycdnStorageApi
observability = PlycdnObservabilityApi(PlycdnStorageApi(os.environ["PLYCDN_ADMIN_KEY"]))
settings = observability.update_observability_settings(org_id, {"region": "mumbai", "retentionDays": 30})
Retention is 7, 15, 30, 90, 180 or 365 days, up to your plan's longest. Shortening it deletes older data for good; the page asks you to confirm. Details: Turning it on.
3Create an ingest key
In the dashboard: Observability → Ingest keys → Create ingest key. Name it for the system that will send (for
example checkout-production). The key is shown once, with a Test log command that already contains it: copy the
key into your secret store, then choose I have saved it.
From your code:
POST /api/content/v1/observability/ingest-keys
{"name": "checkout-production"}
ASP.NET Core
var key = await observability.CreateIngestKeyAsync(orgId, new IngestKeyCreate("checkout-production"));
await secrets.SaveAsync("plycdn-ingest-checkout", key.Key); // shown once
Node.js
const key = await observability.createIngestKey(orgId, { name: 'checkout-production' });
await secrets.save('plycdn-ingest-checkout', key.key); // shown once
Python
key = observability.create_ingest_key(org_id, {"name": "checkout-production"})
secrets.save("plycdn-ingest-checkout", key["key"]) # shown once
Make one key per application, environment or customer, so that you can Revoke one without touching the rest. Up to 20 are active at once; a revoked key stops working within a minute.
4Send your first data
The quickest test is one JSON line from a terminal, to the ndjson address your settings show (this one is Mumbai's):
export PLYCDN_INGEST_KEY=plyt_YOUR_KEY # replace
curl -X POST https://in-mum.telemetry.plycdn.com:4318/v1/ndjson \
-H "Authorization: Bearer $PLYCDN_INGEST_KEY" -H "Content-Type: application/x-ndjson" \
--data-binary #39;{"level":"info","service":"first-test","message":"hello from my first log"}\n'
# -> {"accepted":1}
Then point your real applications at plycdn. For any OpenTelemetry application, with no code change:
export OTEL_EXPORTER_OTLP_ENDPOINT=https://in-mum.telemetry.plycdn.com:4318
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_HEADERS=X-Plycdn-Ingest-Key=plyt_YOUR_KEY
export OTEL_SERVICE_NAME=checkout
The settings page shows these under OpenTelemetry settings. Other senders, each with a complete configuration: OpenTelemetry with no code change, Prometheus, Fluent Bit, syslog and JSON lines.
5Find what you need
Ask with SQL, or with KQL if your team already writes it. The dashboard has no query box: queries run from your server or a terminal, with an admin key, and return only your own organization's rows.
POST /api/content/v1/observability/query
{"dialect": "kql", "sql": "logs | where Timestamp > ago(1h) and SeverityNumber >= 17 | summarize errors = count() by ServiceName | order by errors desc"}
ASP.NET Core
var errors = await observability.QueryTelemetryAsync(orgId, new TelemetryQuery {
Sql = "SELECT ServiceName, count() AS errors FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)} AND SeverityNumber >= 17 GROUP BY ServiceName" });
var same = await observability.QueryTelemetryAsync(orgId, new TelemetryQuery {
Dialect = "kql", Sql = "logs | where Timestamp > ago(1h) and SeverityNumber >= 17 | summarize errors = count() by ServiceName" });
Node.js
const errors = await observability.queryTelemetry(orgId, {
sql: 'SELECT ServiceName, count() AS errors FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)} AND SeverityNumber >= 17 GROUP BY ServiceName' });
const same = await observability.queryTelemetry(orgId, {
dialect: 'kql', sql: 'logs | where Timestamp > ago(1h) and SeverityNumber >= 17 | summarize errors = count() by ServiceName' });
Python
errors = observability.query_telemetry(org_id, {
"sql": "SELECT ServiceName, count() AS errors FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)} AND SeverityNumber >= 17 GROUP BY ServiceName"})
same = observability.query_telemetry(org_id, {
"dialect": "kql", "sql": "logs | where Timestamp > ago(1h) and SeverityNumber >= 17 | summarize errors = count() by ServiceName"})
Without from and to, {from:DateTime64(3)} is one hour ago. Put values in params and name them as {name:Type};
never paste them into the text. A query runs for at most 30 seconds (up to 120 where your account's ceiling was raised) and
returns up to 100,000 rows; for more, use an
export. To watch logs as they arrive, use the
live tail; for one request across your services,
one trace. The tables and their columns: The tables;
KQL: Asking questions with KQL.
6Find your slow queries
In the dashboard: Observability → Slow queries. Choose the Period and Took at least; each row shows the Query, when it Finished, how long it Took, the Rows read and the Memory it used.
From your code:
GET /api/content/v1/observability/slow-queries?minDurationMs=2000&limit=50
ASP.NET Core
var slow = await observability.GetSlowQueriesAsync(orgId, minDurationMs: 2000); // slow.Items, slow.NextCursor
Node.js
const slow = await observability.getSlowQueries(orgId, { minDurationMs: 2000 }); // slow.items, slow.nextCursor
Python
slow = observability.get_slow_queries(org_id, min_duration_ms=2000) # slow["items"], slow["nextCursor"]
Queries are listed for 30 days, SQL and KQL alike. Narrow the slowest ones by time and ServiceName first: those
filters read the least. Details: Slow queries.
7Check a query before you run it
Find out whether a query is allowed and what columns it would return, without waiting for its rows. This suits an editor in your own tool, a test in your pipeline, or an assistant that writes queries.
In the dashboard: there is no query box: checking is a call from your server or a terminal, with an admin key. What a check read appears under Observability → Slow queries like any other statement.
From your code:
POST /api/content/v1/observability/validate
{"dialect": "kql", "sql": "logs | where Timestamp > ago(1h) | summarize errors = count() by ServiceName"}
ASP.NET Core
var check = await observability.ValidateTelemetryQueryAsync(orgId, new TelemetryQuery {
Dialect = "kql", Sql = "logs | where Timestamp > ago(1h) | summarize errors = count() by ServiceName" });
if (!check.Valid)
Console.WriteLine(quot;{check.Error!.Message} (line {check.Error.Line}, column {check.Error.Column})");
else
foreach (var column in check.Columns!) Console.WriteLine(quot;{column.Name} {column.Type}");
Node.js
const check = await observability.validateTelemetryQuery(orgId, {
dialect: 'kql', sql: 'logs | where Timestamp > ago(1h) | summarize errors = count() by ServiceName' });
if (!check.valid) console.log(`${check.error?.message} (line ${check.error?.line}, column ${check.error?.column})`);
else console.log(check.columns);
Python
check = observability.validate_telemetry_query(org_id, {
"dialect": "kql", "sql": "logs | where Timestamp > ago(1h) | summarize errors = count() by ServiceName"})
if not check["valid"]:
error = check["error"]
print(f"{error['message']} (line {error['line']}, column {error['column']})")
else:
print(check["columns"])
A query that does not check out is an answer (valid: false and an error), not a thrown error. line and column point into
your own text, and are empty when the position cannot be told (a KQL query that plycdn had to rewrite). The call also answers the
SQL words DESCRIBE and EXPLAIN, and so does the query call itself.
Checking reads no table data, except what a scalar subquery (SQL) or an in (<query>) (KQL) needs to work out its value, and
WITH TOTALS reads a little; the answer's stats show what was read, and that counts as read like any query.
Check it works: send SELECT 1 and then SELEC 1. The first answers valid: true with a column; the second answers
valid: false with a message. Details: Check a query before running it;
what you can write: KQL reference, also from code with GetTelemetryReferenceAsync,
getTelemetryReference and get_telemetry_reference.
If it fails
400 query_invaliddoes not come back from a check: readvalidanderrorinstead. For a missing key or plan you get403 admin_key_requiredor403 not_included_in_plan, and409 observability_not_enableduntil you switch it on.429 query_busy: eight of your statements are running. Wait theRetry-After(1 second) and send it again.429 read_quota_exceeded: your organization has read its hourly allowance (Starter only; Growth and Enterprise have no hourly read limit). Wait theRetry-After(up to an hour), or move to Growth.429 rate_limited: too many calls through the plycdn address. Wait theRetry-After.
The Node.js and Python packages send a retryable 429 again for you, waiting as long as asked. The .NET package does not: it
throws a PlycdnException; wait ex.RetryAfter and send the call again.
8Keep a query within a time limit
A query may run for 30 seconds and an export for 10 minutes. Ask for less so that a mistake in a script costs less. Where your account's ceiling was raised (up to 120 seconds), a query may also ask for more, up to that ceiling.
In the dashboard: not a setting: you choose it in each call. Observability → Slow queries shows which of your queries ran long.
From your code: add maxExecutionMs (1,000 to 30,000 for a query, (up to 120,000 where your account's ceiling was raised); 1,000 to 600,000 for an export).
POST /api/content/v1/observability/query
{"sql": "SELECT count() FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}", "maxExecutionMs": 5000}
ASP.NET Core
try
{
var result = await observability.QueryTelemetryAsync(orgId, new TelemetryQuery {
Sql = "SELECT count() FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}", MaxExecutionMs = 5000 });
}
catch (PlycdnException ex) when (ex.Code == "query_timeout")
{
// Took longer than 5 seconds: narrow the time range, or export instead.
}
catch (PlycdnException ex) when (ex.Code == "query_busy" || ex.Code == "read_quota_exceeded")
{
await Task.Delay(ex.RetryAfter ?? TimeSpan.FromSeconds(1)); // then send the same call again
}
Node.js
import { PlycdnError } from '@plycdn/node';
try {
const result = await observability.queryTelemetry(orgId, {
sql: 'SELECT count() FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}', maxExecutionMs: 5000 });
} catch (error) {
if (error instanceof PlycdnError && error.code === 'query_timeout') {
// Took longer than 5 seconds: narrow the time range, or export instead.
} else throw error; // a busy or over-allowance 429 was already sent again for you
}
Python
from plycdn import PlycdnError
try:
result = observability.query_telemetry(org_id, {
"sql": "SELECT count() FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}", "maxExecutionMs": 5000})
except PlycdnError as error:
if error.code != "query_timeout":
raise # a busy or over-allowance 429 was already sent again for you
# Took longer than 5 seconds: narrow the time range, or export instead.
In SQL you can also write SELECT ... SETTINGS max_execution_time = 5 (seconds). In KQL use maxExecutionMs. The limits are all
in Read limits.
Check it works: run a query with "maxExecutionMs": 1000 over a wide range. If it needs longer, the answer is query_timeout;
with 500 you get validation_error.
If it fails
422 validation_error:maxExecutionMsis outside 1,000 to 30,000, or above your raised ceiling (1,000 to 600,000 for an export). Use a value in range; the reference'slimits.queryMaxExecutionMsis your own ceiling.400 query_timeout: the query ran out of time. It is not retryable as it is: narrow the time range, filter byServiceName, or export.429 query_busy: eight statements are running. WaitRetry-After(1 second) and send it again.429 read_quota_exceeded: the hourly allowance is used up (Starter only). WaitRetry-After(1 to 3,600 seconds). Narrower queries read less.429 rate_limited: too many calls through the plycdn address. WaitRetry-After.
9See what a query read
Every answer says how much the query read, and that is what counts toward your hourly allowance on Starter (Growth and Enterprise have none). For a download, look the statement up afterwards.
In the dashboard: Observability → Slow queries lists your statements with Rows read and Memory; filter by Query id to find the one you ran.
From your code: a query, a check and a trace answer with stats (readBytes, readRows, elapsedMs). A download is a
stream, so its answer carries an X-Query-Id header; a few seconds later look it up:
GET /api/content/v1/observability/slow-queries?queryId=<id>
ASP.NET Core
var result = await observability.QueryTelemetryAsync(orgId, new TelemetryQuery { Sql = "SELECT count() FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}" });
Console.WriteLine(quot;{result.Stats.ReadRows} rows, {result.Stats.ReadBytes} bytes, {result.Stats.ElapsedMs} ms");
using var stream = await observability.ExportTelemetryAsync(orgId, new TelemetryExport { Sql = "SELECT * FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}" });
var queryId = (stream as ExportStream)?.QueryId;
await stream.CopyToAsync(Stream.Null);
await Task.Delay(TimeSpan.FromSeconds(10)); // the statement log is written about every 7.5 seconds
var found = await observability.FindSlowQueryAsync(orgId, queryId!); // found.Items[0].ReadBytes
Node.js
const result = await observability.queryTelemetry(orgId, { sql: 'SELECT count() FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}' });
console.log(result.stats.readRows, result.stats.readBytes, result.stats.elapsedMs);
const stream = await observability.exportTelemetry(orgId, { sql: 'SELECT * FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}' });
const queryId = stream.queryId; // read it now; consume the stream, then look the statement up
for await (const _chunk of stream) { /* write the file */ }
await new Promise((resolve) => setTimeout(resolve, 10_000)); // the statement log is written about every 7.5 seconds
const found = await observability.getSlowQueries(orgId, { queryId }); // found.items[0].readBytes
Python
import time
result = observability.query_telemetry(org_id, {"sql": "SELECT count() FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}"})
print(result["stats"]["readRows"], result["stats"]["readBytes"], result["stats"]["elapsedMs"])
stream = observability.export_telemetry(org_id, {"sql": "SELECT * FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)}"})
for chunk in stream:
pass # write the file; stream.query_id is readable once the first chunk has arrived
time.sleep(10) # the statement log is written about every 7.5 seconds
found = observability.get_slow_queries(org_id, query_id=stream.query_id) # found["items"][0]["readBytes"]
A live tail's events carry stats for each poll, and its last event the total. Details:
What a query read.
Check it works: the readBytes of a narrow query is much smaller than that of the same query over a week. The download's
statement appears in Observability → Slow queries when you filter by its id (a few seconds after it ends).
If it fails
- The lookup is empty: wait another 10 seconds; the statement log is written about every 7.5 seconds. The id must be the one the download answered with, and the range (last 24 hours by default) must include it.
422 validation_error: thefromandtoof the lookup are backwards or reach back more than 30 days.429 read_quota_exceededorquery_busy: waitRetry-Afterand send it again (Node.js and Python do this for you).
10Check what you sent and what was refused
Compare what reached us with what was stored, and see which data was not kept and why.
In the dashboard: Observability → Volumes. Each day shows Received (as you sent it), Stored (as plycdn stores it, the billed size), Kept and Read. A day that has not settled yet is marked as partial; today always is. Under Refused and removed, narrow the table by signal; the page address keeps the choice, so you can share the view.
From your code:
GET /api/content/v1/observability/volumes?from=2026-10-16&to=2026-10-16
ASP.NET Core
var volumes = await observability.GetTelemetryVolumesAsync(orgId, new DateOnly(2026, 10, 16), new DateOnly(2026, 10, 16));
foreach (var day in volumes.Days)
{
Console.WriteLine(quot;{day.Day}: received {day.ReceivedGb} GB, stored {day.IngestedGb} GB, complete {day.Complete}");
foreach (var refused in day.Rejected)
Console.WriteLine(quot; {refused.Signal} {refused.Reason}: {refused.Records ?? refused.Requests}");
}
Node.js
const volumes = await observability.getTelemetryVolumes(orgId, { from: '2026-10-16', to: '2026-10-16' });
for (const day of volumes.days) {
console.log(day.day, 'received', day.receivedGb, 'stored', day.ingestedGb, 'complete', day.complete);
for (const refused of day.rejected ?? []) console.log(' ', refused.signal, refused.reason, refused.records ?? refused.requests);
}
Python
volumes = observability.get_telemetry_volumes(org_id, from_="2026-10-16", to="2026-10-16")
for day in volumes["days"]:
print(day["day"], "received", day.get("receivedGb"), "stored", day["ingestedGb"], "complete", day.get("complete"))
for refused in day.get("rejected", []):
print(" ", refused["signal"], refused["reason"], refused["records"] if refused["records"] is not None else refused["requests"])
Reading the answer:
- Received is the size of your requests as sent (compressed, if you compressed) and is not billed. Stored is the size of the rows as plycdn stores them and is what is billed: one small span of about 120 bytes as sent is about 450 bytes stored, because the details a request states once are written on every row.
- Kept is a daily level: the data held at the end of the day. A month's sum is GB-days.
rejectedlists, per signal:older_than_retention(records older than your retention when they arrived; stored, counted as stored, and removed at once),ingest_invalid,ingest_too_largeandnot_stored(requests).- A day is
completeonce every figure has counted it after 00:05 UTC the next day. Today never is: ask forfromandtoas today to see what has arrived so far. - A request is answered once it is safely queued, so there is no per-record answer; this table is where refused data is counted. Why there is no per-record partial success.
- From 1.36.0 the tag we add to each row to know which organization it belongs to is no longer counted: Stored steps down on the day you move to 1.36.0, and earlier days are not changed.
Check it works: send one log line and look at today's row: Received grows within a minute and the day shows as partial.
Send a request with a record dated a year ago to an organization that keeps 30 days: tomorrow's view of that day lists
older_than_retention for logs.
If it fails
- Nothing under Refused and removed but data is missing: check the sender's own errors first. A request refused at our front door (a wrong key, a request over 5 MB as sent) was answered at once and is not counted. See Nothing arrived?.
- Stored is far above Received: many small records. Batch them, and drop attributes you never read.
422 validation_error: the range is backwards or longer than 400 days.
11Serve your own customers
If you run a product for customers of your own, give each one a sub-organization. Each then has its own region,
retention, ingest keys and rows, never visible to the others, and one admin key of yours covers them all: send the
sub-organization's id as X-Organization-Id. The full walk-through, in every language:
One sub-organization per customer of yours.
12In the browser
There is nothing to add to the React or Angular packages: every call on this page needs an admin key, which belongs on your server, and sending uses an ingest key in your applications' own configuration. To show results inside your own application, call your server, and have it make the query.
13Check it works
- Observability → Settings shows Where to send data with your region's addresses.
- Run the Test log command from Observability → Ingest keys, or the
curlabove. The answer is{"accepted":1}. - Open Observability → Volumes. Within a minute, today's row under Day by day shows Records and Received; the page checks again every 15 seconds.
- Run the query from Find what you need with
"sql": "SELECT Timestamp, ServiceName, Body FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)} ORDER BY Timestamp DESC LIMIT 10". Yourhello from my first logline is the first row.
14Questions and problems
401 ingest_key_invalid when sending. The key is mistyped, has a space or a line break in it, or was revoked.
Compare its first characters with Starts with in Observability → Ingest keys, or create a new one.
403 ingest_wrong_region. You sent to another region's address. Use the addresses on your settings page.
{"accepted":1}, but nothing shows. Data appears within a minute of arriving. A query looks back one hour unless you
pass from and to, and a record stamped with an old time is stored at that time: widen the range. The full list:
Nothing arrived?.
409 observability_not_enabled. Switch it on first: Observability → Settings.
409 observability_region_fixed. The region is chosen once. To keep data elsewhere as well, use a sub-organization
for it.
403 plan_limit_reached. The retention you chose is longer than your plan allows.
409 ingest_key_limit_reached. Twenty keys are active. Revoke one you no longer use.
400 query_invalid. The query is not one read-only SQL or KQL statement over your own tables, or it has a mistake;
the detail says what.
400 query_too_expensive or query_timeout. It reads or returns too much, or runs over 30 seconds (or over your maxExecutionMs). Narrow the time
range, filter by ServiceName, or use an export. Slow queries shows what it read.
429 query_busy, 429 read_quota_exceeded or 429 rate_limited. Retryable. Wait the Retry-After seconds and send the same
request again; the Node.js and Python packages do it for you, the .NET package throws and you wait RetryAfter.
422 validation_error on a query. maxExecutionMs is outside 1,000 to 30,000, or above your raised ceiling (1,000 to
600,000 for an export).
403 admin_key_required. These calls need an admin key. Keep it on your server.
503 observability_unavailable. We could not answer just then. Send the same request again; OpenTelemetry
exporters do this on their own, and nothing you sent is lost.
Every code: Errors.