plycdn Observability — send logs, metrics and traces, then query, export and tail them
Audience: developers and operators who want the logs, metrics and traces of their own applications kept somewhere they can search them. You send data from your applications and servers with the tools you already use (OpenTelemetry, Prometheus, or plain JSON), and plycdn keeps it for as long as you choose, in the region you choose. You read it back with SQL, download it, follow logs live as they arrive, open one trace, and, if you like, have a copy written every night into a bucket of your own.
Observability is part of every plan. How long data can be kept depends on the plan: up to 30 days on Starter, 90
on Growth and 365 on Enterprise (GET /plan lists the feature observability and, as
observability_retention_days, your longest choice; see Plans and prices). The wire contract is
in the API reference; the server packages for .NET, Node.js and Python carry every call
(ASP.NET Core, Node.js,
Python). The dashboard has an Observability section with your settings, your
ingest keys and your volumes.
Which key. Every call in this guide that you make to the API needs an admin API key (a standard key is
refused with admin_key_required, 403). Sending data does not use an API key at all: it uses an ingest key,
which you create below and which can only send data.
1Quick start
About five minutes from nothing to a log line you can read back.
You need
- A plan that includes Observability. Every plan does;
GET /planlistsobservability. - An admin API key, for the terminal route below: Developers → API keys, scope Admin.
- Your organization id: Account → Profile & organization. It is sent as
X-Organization-Id. - Only if you will call this from your own code: the server packages at 1.14.0 or later (where to get them). This quick start needs no package.
In the dashboard (no code)
- Observability → Settings. Choose a region and press Switch on. The page then shows Send your first data, with every address filled in.
- Observability → Ingest keys → Create ingest key. Name it for the system that will send. The key is shown once, with a test command that already contains it. Copy the key.
- Run the test command in a terminal. Then open Observability → Volumes: the first rows appear within a minute of arriving, and the page checks again every 15 seconds.
From a terminal
Replace the three marked values (YOUR_ADMIN_KEY, YOUR_ORGANIZATION_ID, and later plyt_YOUR_KEY); everything else
runs as it stands.
export API=https://api.plycdn.com/api/content/v1
export PLYCDN_ADMIN_KEY=YOUR_ADMIN_KEY # replace
export PLYCDN_ORG=YOUR_ORGANIZATION_ID # replace
# 1. Switch it on (once). Mumbai is open at launch; the answer lists the addresses to send to.
curl -X PATCH $API/observability/settings \
-H "Authorization: ApiKey $PLYCDN_ADMIN_KEY" -H "X-Organization-Id: $PLYCDN_ORG" \
-H "Content-Type: application/json" \
-d '{ "region": "mumbai", "retentionDays": 30 }'
# 2. Create an ingest key. Copy "key" from the answer: it is shown once.
curl -X POST $API/observability/ingest-keys \
-H "Authorization: ApiKey $PLYCDN_ADMIN_KEY" -H "X-Organization-Id: $PLYCDN_ORG" \
-H "Content-Type: application/json" \
-d '{ "name": "first-test" }'
# 3. Send one log line. Use the "ndjson" address from step 1 (this one is Mumbai's) and the key from step 2.
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}
See it arrived. Either read it back with SQL (the last hour, newest first):
curl -X POST $API/observability/query \
-H "Authorization: ApiKey $PLYCDN_ADMIN_KEY" -H "X-Organization-Id: $PLYCDN_ORG" \
-H "Content-Type: application/json" \
-d '{ "sql": "SELECT Timestamp, ServiceName, Body FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)} ORDER BY Timestamp DESC LIMIT 10" }'
or follow your logs as they arrive (about 8 seconds behind; press Ctrl+C to stop), then send the line again from another terminal:
curl -N "$API/observability/logs/tail?service=first-test" -H "Accept: text/event-stream" \
-H "Authorization: ApiKey $PLYCDN_ADMIN_KEY" -H "X-Organization-Id: $PLYCDN_ORG"
If {"accepted":1} came back but you see nothing yet, wait a minute and ask again; Nothing
arrived? has the rest.
Next: send from your real applications with OpenTelemetry and no code change, Prometheus, Fluent Bit or rsyslog; look things up with SQL; or give each of your own customers its own organization.
2Turning it on: region, retention and archive
Observability is off until your organization picks a region. GET /api/content/v1/observability/settings answers
{ "enabled": false } until then. Mumbai (India) is open at launch; a region that does not offer observability is
refused with region_not_available (400).
PATCH /api/content/v1/observability/settings
{ "region": "mumbai", "retentionDays": 90, "archiveBucketId": "6f5c1c9e-..." }
- Region. Chosen once, when you enable. It cannot be changed afterwards (
observability_region_fixed, 409). Your data is kept in that region, and ingest keys only work against that region's addresses. - Retention. How long data is kept: 7, 15, 30, 90, 180 or 365 days, up to what your plan allows (more than
your plan allows is
plan_limit_reached, 403). Without a value, enabling starts at 30 days, or at your plan's longest choice if that is shorter. Data older than the retention is deleted automatically.- Shortening deletes older data permanently, usually within minutes. There is no undo.
- Lengthening keeps only what is still held. Data that was already past the old period when you lengthened it is gone and does not come back; the longer period applies from then on.
- Every change is recorded in your audit log as
observability.retention_changed, with the old and the new number of days.
- Archive. Optionally name one of your own active file buckets in the same region as
archiveBucketId. See Archiving to your own bucket. Send"archiveBucketId": nullto stop.
The settings answer carries the addresses to send data to, with your region's name already in them:
{
"enabled": true, "region": "mumbai", "retentionDays": 90, "archiveBucketId": null,
"enabledAt": "2026-10-16T09:30:00Z", "updatedAt": "2026-10-16T09:30:00Z",
"endpoints": {
"otlpHttp": "https://in-mum.telemetry.plycdn.com:4318",
"otlpGrpc": "in-mum.telemetry.plycdn.com:4317",
"prometheusRemoteWrite": "https://in-mum.telemetry.plycdn.com:4318/api/v1/write",
"ndjson": "https://in-mum.telemetry.plycdn.com:4318/v1/ndjson",
"syslog": "in-mum.telemetry.plycdn.com:6514",
"fluentForward": "in-mum.telemetry.plycdn.com:24224"
}
}
If your plan no longer includes observability, you can still read your settings, shorten the retention and stop the
archive, so you can reduce what you hold; turning it on, lengthening it and naming an archive bucket need the
feature.
3Ingest keys
An ingest key lets one sender put data into your organization, and nothing else. It cannot read data or call any other part of the API. Create one for each application, environment or customer of yours that sends data, so that you can revoke one without touching the rest.
POST /api/content/v1/observability/ingest-keys
{ "name": "checkout-service" }
{ "id": "0b1f4c2e-5d3a-4e0b-9a77-3c1d2f6a8b10", "name": "checkout-service", "prefix": "plyt_K3J",
"key": "plyt_K3JXQ2M7ZB4NVHD6TPW5LFAY3RC2EG7S", "createdAt": "2026-10-16T09:31:00Z", "revokedAt": null }
- The key (
plyt_and 32 characters) is in this answer only. plycdn keeps a one-way fingerprint and the first eight characters (prefix), so it cannot show the key again. Put it in your secret store now. GET /api/content/v1/observability/ingest-keyslists your keys with their prefixes, so you can tell which is which, and whether they have been revoked.DELETE /api/content/v1/observability/ingest-keys/{id}revokes one (204; revoking one that is already revoked changes nothing).- A revoked key stops being accepted within a minute.
- At most 20 keys can be active at once (
ingest_key_limit_reached, 409). Revoke one first. - Creating and revoking keys is recorded in your audit log as
observability.key_createdandobservability.key_revoked.
Send the key in either of two headers, whichever your sender can set:
Authorization: Bearer plyt_YOUR_KEY
X-Plycdn-Ingest-Key: plyt_YOUR_KEY
The word Bearer is not case-sensitive. A key goes in a header only, never in the address.
4Sending data
Each protocol has its own address, all on the host in endpoints. Use TLS (https) everywhere. A key only works
against the region of your organization: sent anywhere else it is refused with ingest_wrong_region (403).
| What you send | Address | Notes |
|---|---|---|
| OpenTelemetry over HTTP | https://in-mum.telemetry.plycdn.com:4318/v1/logs, /v1/metrics, /v1/traces |
Protobuf or JSON |
| OpenTelemetry over gRPC | in-mum.telemetry.plycdn.com:4317 |
Logs, metrics and traces on the same address; TLS |
| Prometheus remote write | https://in-mum.telemetry.plycdn.com:4318/api/v1/write |
Remote write 2.0 only (below) |
| JSON lines | https://in-mum.telemetry.plycdn.com:4318/v1/ndjson |
For logs from a script or a shipper |
| Syslog over TLS | in-mum.telemetry.plycdn.com:6514 |
RFC 5424 or RFC 3164; the key is in the message (below) |
| Fluent forward over TLS | in-mum.telemetry.plycdn.com:24224 |
The forward output of Fluent Bit and Fluentd; the key is the user name and password (below) |
(in-mum is Mumbai's label; use the addresses from your own settings answer.)
OpenTelemetry
Point any OpenTelemetry SDK at plycdn with the standard environment variables:
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 key travels in X-Plycdn-Ingest-Key here because many SDKs do not accept a space inside the headers
variable. If yours does, Authorization=Bearer plyt_YOUR_KEY works as well.) For gRPC, set the protocol to grpc and
the endpoint to https://in-mum.telemetry.plycdn.com:4317.
If you run an OpenTelemetry Collector of your own between your applications and plycdn, this is a complete configuration: it receives from your applications, batches, and sends all three kinds of data on.
receivers:
otlp:
protocols:
grpc:
http:
processors:
batch:
exporters:
otlphttp/plycdn:
endpoint: https://in-mum.telemetry.plycdn.com:4318
headers:
X-Plycdn-Ingest-Key: plyt_YOUR_KEY
service:
pipelines:
logs: { receivers: [otlp], processors: [batch], exporters: [otlphttp/plycdn] }
metrics: { receivers: [otlp], processors: [batch], exporters: [otlphttp/plycdn] }
traces: { receivers: [otlp], processors: [batch], exporters: [otlphttp/plycdn] }
service.name becomes the ServiceName of every log, span and metric, so set it. A request is at most 5 MB.
OpenTelemetry with no code change
You do not have to edit your application to send traces, metrics and logs. Each language has an automatic
setup from the OpenTelemetry project that instruments the common frameworks and reads the variables above. Set the
variables, start the application through the setup, and data flows. (OTEL_SERVICE_NAME becomes the ServiceName.)
Node.js
npm install @opentelemetry/api @opentelemetry/auto-instrumentations-node
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
export OTEL_TRACES_EXPORTER=otlp OTEL_METRICS_EXPORTER=otlp OTEL_LOGS_EXPORTER=otlp
node --require @opentelemetry/auto-instrumentations-node/register app.js
Logs written through pino, winston or bunyan are sent along with the trace they belong to.
Python
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install # adds instrumentation for the packages you use
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
export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
opentelemetry-instrument python app.py
The standard logging module is sent as logs, with the trace id of the request being served.
.NET
curl -L -O https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/latest/download/otel-dotnet-auto-install.sh
chmod +x otel-dotnet-auto-install.sh && ./otel-dotnet-auto-install.sh
. $HOME/.otel-dotnet-auto/instrument.sh # sets up the current shell
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
dotnet YourApp.dll
ASP.NET Core requests, outgoing HTTP calls and ILogger output are sent. Set the same variables in the
environment of a service, a container or a hosting plan to run it there. Whichever language you use, a first
trace appears within a minute of the first request your application serves; open it with One trace.
Prometheus remote write
plycdn accepts remote write 2.0 only. Use Prometheus 3.0 or later and ask it for that format; an older sender, or
remote write 1.0, is refused with ingest_invalid. If you cannot upgrade, send the same metrics as OpenTelemetry.
remote_write:
- url: https://in-mum.telemetry.plycdn.com:4318/api/v1/write
protobuf_message: io.prometheus.write.v2.Request
authorization:
credentials: plyt_YOUR_KEY
A request is at most 10 MB. Samples arrive in the metric tables below, one row per sample.
JSON lines
For logs from a script, a function or a shipper that has no OpenTelemetry output, post one JSON object per line.
curl https://in-mum.telemetry.plycdn.com:4318/v1/ndjson \
-H "X-Plycdn-Ingest-Key: plyt_YOUR_KEY" \
-H "Content-Type: application/x-ndjson" \
--data-binary @app.log.ndjson
A JSON array of objects (Content-Type: application/json) works too. The answer is 200 { "accepted": 2 }.
| Key in the line | Becomes | Notes |
|---|---|---|
timestamp (or time, @timestamp) |
The log's time | An ISO 8601 string, or a number of seconds, milliseconds, microseconds or nanoseconds since 1970. Without one, the time plycdn received the line |
level (or severity) |
SeverityText and SeverityNumber |
trace 1, debug 5, info 9, warn (or warning) 13, error 17, fatal 21; the words are not case-sensitive, and any other word gives 0 |
message (or msg, body) |
Body |
|
service (or service.name) |
ServiceName |
|
| everything else | LogAttributes |
Stored as text, so "order": 1042 is read back as '1042'; a nested object is stored as JSON text |
Limits: 5 MB of body, 10,000 records and 128 attributes per record in one request. A line that cannot
be read refuses the whole request with ingest_invalid (400) and a detail that names the line (line 3: not valid JSON), so you never have to guess which lines were kept: fix the line and send the request again.
Syslog over TLS
Point rsyslog, syslog-ng or any syslog sender that can use TLS at in-mum.telemetry.plycdn.com:6514. Messages in
RFC 5424 or RFC 3164 format are read, framed either by a length in front of each message (octet counting) or one per
line. Put your ingest key in the message, in one of two places:
- a structured-data element named
plycdnwith akeyparameter:[plycdn key="plyt_YOUR_KEY"](RFC 5424); - or as the first word of the message text:
plyt_YOUR_KEY the rest of the message(either format; in RFC 3164 it may stand directly after the priority, with no host name or time before it).
The key is taken out of the message and never stored. Where the key sits in the message is the only thing that decides which organization the data belongs to.
| In the message | Becomes |
|---|---|
| The text | Body |
| Priority | SeverityNumber and SeverityText: emergency 21, alert 20, critical 18, error 17, warning 13, notice 10, info 9, debug 5 |
| The time | The log's time. A message in the older RFC 3164 format has no year or time zone: it is read as UTC in the current year. Without a time, the time plycdn received the message |
| Host name, application name, process id, message id, facility | LogAttributes syslog.hostname, syslog.appname, syslog.proc_id, syslog.msgid, syslog.facility |
| Other structured-data elements | LogAttributes named syslog.sd.<element>.<parameter>. An element or parameter whose name starts with plycdn (in any capitals) is ignored |
A message is at most 64 KB. A message without a key, or one that cannot be read as syslog, is dropped and counted; a connection must show a valid key within 30 seconds and its first 20 messages, or it is closed, however slowly it sends. A key that is unknown, revoked, from another region, not allowed to send or not shaped like an ingest key closes the connection (a sender that reconnects is closed again); the codes in the table below describe why, but a syslog connection has no way to show them, so check the key first when a sender's connection keeps closing. Each message counts as one request against the key's rate (below); messages over it are dropped. A connection that sends nothing for 5 minutes is closed, and senders reconnect on their own. Syslog has no acknowledgements: if you need to know a record arrived, use Fluent forward or OpenTelemetry.
rsyslog:
global(DefaultNetstreamDriver="gtls" DefaultNetstreamDriverCAFile="/etc/ssl/certs/ca-certificates.crt")
template(name="plycdn" type="string"
string="<%PRI%>1 %TIMESTAMP:::date-rfc3339% %HOSTNAME% %APP-NAME% %syslogtag:R,ERE,1,ZERO:\\[([0-9]+)\\]--end% %MSGID% [plycdn key=\"plyt_YOUR_KEY\"]%msg:::drop-last-lf%\n")
action(type="omfwd" target="in-mum.telemetry.plycdn.com" port="6514" protocol="tcp" template="plycdn"
StreamDriver="gtls" StreamDriverMode="1"
StreamDriverAuthMode="x509/name" StreamDriverPermittedPeers="in-mum.telemetry.plycdn.com")
syslog-ng:
destination d_plycdn {
network("in-mum.telemetry.plycdn.com" port(6514) transport("tls")
tls(ca-dir("/etc/ssl/certs") peer-verify(required-trusted))
template("<${PRI}>1 ${ISODATE} ${HOST} ${PROGRAM:--} ${PID:--} ${MSGID:--} [plycdn key=\"plyt_YOUR_KEY\"] ${MESSAGE}\n"));
};
log { source(s_local); destination(d_plycdn); };
Keep the key in a file only your agent can read, and write the configuration from it, as you would any secret.
Fluent forward
Fluent Bit and Fluentd can send with their forward output. Use TLS, the shared key plycdn, and your ingest key as both
the user name and the password. The key is checked when the connection opens and again on every batch, so a revoked key
stops a connection that is already open within a minute.
| In the record | Becomes |
|---|---|
log (or, without it, message) |
Body |
| The tag | LogAttributes fluent.tag |
| The record's time | The log's time (without one, the time plycdn received it) |
| Every other field | LogAttributes, stored as text; a nested object is stored as JSON text |
All four forward formats are read (a single message, a list of entries, and packed or compressed chunks). A chunk is at
most 10 MB (once uncompressed) and 10,000 records; a field whose name starts with plycdn (in any capitals) is ignored; a record that is
not a map is dropped and counted. A record may have up to 128 fields, a list or object inside it up to 1,024
elements, and lists and objects may nest up to 32 levels deep (the record itself is the first); a record over any of these limits is skipped and counted, and the rest of its chunk is kept and acknowledged, so
nothing is ever sent again for it. Turn acknowledgements on (Require_ack_response in Fluent Bit, require_ack_response in Fluentd): the answer means plycdn
has received the chunk, not yet stored it, and it is withheld when the chunk could not be taken (too many records
waiting), so the agent sends it again (records of that chunk that were already taken may then arrive twice). Without acknowledgements, a chunk that is refused for being over the rate is lost. A key that is wrong, unknown, revoked or from another region is refused at the handshake. Over the key's rate
(below) the connection is closed and an agent that asked for acknowledgements sends the unacknowledged chunk again.
A connection that has not finished its handshake within 10 seconds is closed. Each key may hold 200 connections open and
each organization 400.
Fluent Bit:
[OUTPUT]
Name forward
Match *
Host in-mum.telemetry.plycdn.com
Port 24224
tls On
tls.verify On
Shared_Key plycdn
Self_Hostname my-host
Username plyt_YOUR_KEY
Password plyt_YOUR_KEY
Require_ack_response true
Fluentd:
<match **>
@type forward
transport tls
tls_verify_hostname true
require_ack_response true
<security>
self_hostname my-host
shared_key plycdn
</security>
<server>
host in-mum.telemetry.plycdn.com
port 24224
username plyt_YOUR_KEY
password plyt_YOUR_KEY
</server>
</match>
Fluent Bit over HTTP
If you prefer HTTP, Fluent Bit can send JSON lines:
[FILTER]
Name modify
Match *
# assumes the line is in a field called `log`; rename whichever field holds your message
Rename log message
Add service my-app
[OUTPUT]
Name http
Match *
Host in-mum.telemetry.plycdn.com
Port 4318
URI /v1/ndjson
Format json_lines
json_date_key timestamp
json_date_format iso8601
Header X-Plycdn-Ingest-Key plyt_YOUR_KEY
tls On
Rate and limits when sending
Each ingest key may send 1,000 requests a second (a syslog message or a Fluent forward batch counts as one request), with short bursts up to twice that, and a single sending address is also limited to about 1,000 requests (or new connections, for syslog and Fluent forward) a second. Over it, requests are
refused with rate_limited (429) and a Retry-After header: wait that many seconds and send again. Batch your data
rather than sending one record per request. A higher rate can be agreed with us. OpenTelemetry exporters retry a
429 or a 503 on their own.
Codes you meet while sending
| Code | Status | Meaning |
|---|---|---|
ingest_key_invalid |
401 | No key, or a key that is unknown or revoked |
ingest_wrong_region |
403 | The key belongs to an organization in another region. Send to your region's address |
not_included_in_plan |
403 | Your plan does not include observability |
organization_suspended |
403 | Your organization is suspended |
ingest_refused |
403 | This key cannot send data right now. Contact support |
ingest_too_large |
413 | The body is over the size limit, or has more than 10,000 records. Send smaller requests |
ingest_invalid |
400 | The body could not be read as what it claims to be. For JSON lines, the detail names the line |
rate_limited |
429 | Over the key's rate. Retry-After says how long to wait. Retryable |
observability_unavailable |
503 | We could not take the data just then. Retryable: send the same request again |
These come back as { "code": "...", "retryable": false }, like every error (Errors).
5Nothing arrived?
Start with what the sender was told, then what you can see. A request that was accepted answers 200 (JSON lines:
{"accepted": N}); anything else carries a code.
| What you see | Likely cause | What to do |
|---|---|---|
401 ingest_key_invalid |
The key is mistyped, has a space or line break in it, or was revoked | Compare the first characters with the key's Starts with in Observability → Ingest keys. Create a new key if in doubt |
403 ingest_wrong_region |
The key's organization is in another region | Send to the addresses in your settings answer, not another region's |
403 not_included_in_plan, organization_suspended, ingest_refused |
The plan or the account does not allow sending right now | Contact us |
400 ingest_invalid |
The body is not what its Content-Type says. For JSON lines the detail names the line |
Fix the line and send the request again |
413 ingest_too_large |
More than 5 MB (10 MB for Prometheus) or 10,000 records in one request | Send smaller batches |
429 rate_limited |
Over the key's rate | Wait Retry-After seconds; batch more records per request |
200, but nothing in Volumes or a query |
Data shows within a minute of arriving. A query looks back one hour unless you pass from and to, and a log stamped with an old time is stored at that time |
Wait a minute. Then widen from/to, or look at _ingested: SELECT max(_ingested) FROM obs.logs |
| The query returns no rows | A ServiceName or table that is not the one you sent to: logs are in obs.logs, spans in obs.spans, samples in the obs.metrics_* tables |
SELECT ServiceName, count() FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)} GROUP BY ServiceName |
| Data appears under the wrong organization | The key belongs to that organization. A key decides where data goes, whatever else the request says | Use a key made in the organization you meant |
| The connection fails or times out | Your firewall or a web filter blocks outgoing connections on 4318 (4317 for gRPC, 6514 for syslog, 24224 for Fluent forward), or the machine's clock is far off so the certificate seems invalid | Test with the curl command from the quick start on the same machine |
| An OpenTelemetry exporter says nothing | Exporters retry quietly | Set OTEL_LOG_LEVEL=debug. Check the protocol matches the port (http/protobuf with 4318, grpc with 4317) and that the header has no spaces: X-Plycdn-Ingest-Key=plyt_... |
| Prometheus data is refused | Remote write 1.0, or Prometheus older than 3.0 | Use protobuf_message: io.prometheus.write.v2.Request, or send the metrics as OpenTelemetry |
| A syslog or Fluent forward connection keeps closing | The key is not accepted (unknown, revoked, another region) | Check the key first; these protocols have no way to show the code |
The codes are explained in Rate and limits when sending.
6Asking questions with SQL
POST /api/content/v1/observability/query runs one SQL statement over your own data and answers the columns, the
rows and how much was read.
POST /api/content/v1/observability/query
{
"sql": "SELECT ServiceName, count() AS errors FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)} AND Timestamp < {to:DateTime64(3)} AND SeverityNumber >= 17 GROUP BY ServiceName ORDER BY errors DESC",
"from": "2026-10-16T00:00:00Z",
"to": "2026-10-16T12:00:00Z"
}
{
"columns": [{ "name": "ServiceName", "type": "String" }, { "name": "errors", "type": "UInt64" }],
"rows": [["checkout", "42"], ["search", "7"]],
"stats": { "readBytes": 1843200, "elapsedMs": 12.4 }
}
- Bound values. Put values in
paramsand name them in the statement as{name:Type}({svc:String},{limit:UInt32},{since:DateTime64(3)}); never paste values into the SQL text. Parameter names are lower-case letters, digits and underscores; a value is text, a number ortrue/false, up to 4,096 characters; up to 32 parameters. fromandto(ISO 8601) are always available as{from:DateTime64(3)}and{to:DateTime64(3)}. Without them they are the last hour. They are values for you to use in yourWHERE, not a filter applied for you: a statement that does not filter by time reads everything you keep.- 64-bit whole numbers come back as strings (
count(), durations in nanoseconds) so no reader rounds them. Parse them withBigInt,long.Parseorint(). - Read only. One statement per call:
SELECT, orWITH ... SELECT,DESCRIBEorEXPLAIN. A second statement, anything that changes data, and anything that reads from outside your own tables is refused withquery_invalid(400). Only the tables below exist for you, and you can only see your own rows. - Limits. A statement runs for at most 30 seconds, reads at most 50 GB, and returns at most 100,000
rows (and 64 MB). A statement over a limit is
query_too_expensive(400, narrow the time range or add filters); one that runs out of time isquery_timeout(400). Reads are also capped per hour for your organization: after several heavy statements, aquery_too_expensivecan mean wait a while and retry. Up to eight statements run at the same time. For more rows, use an export. - A statement the SQL cannot understand is
query_invalid, with a message that says why.
The tables
Filter every statement on time: it makes it fast, and it is what you are billed for reading.
| Table | Holds | Time column |
|---|---|---|
obs.logs |
Log records | Timestamp (nanoseconds); TimestampTime is the same moment in whole seconds and is the cheapest to filter on |
obs.spans |
Spans of traces | Timestamp |
obs.trace_ids |
One row per trace: TraceId, Start, End |
Start |
obs.metrics_gauge |
Gauge samples | TimeUnix |
obs.metrics_sum |
Counters and sums | TimeUnix |
obs.metrics_histogram |
Histograms | TimeUnix |
obs.metrics_exponential_histogram |
Exponential histograms | TimeUnix |
obs.metrics_summary |
Summaries | TimeUnix |
Columns:
obs.logs:Timestamp,TimestampTime,TraceId,SpanId,TraceFlags,SeverityText,SeverityNumber(1 to 24; 9 is info, 17 is error),ServiceName,Body,ResourceAttributes(a map),ScopeName,ScopeVersion,ScopeAttributes,LogAttributes(a map),EventName,ResourceSchemaUrl,ScopeSchemaUrl.obs.spans:Timestamp,TraceId,SpanId,ParentSpanId,TraceState,SpanName,SpanKind,ServiceName,ResourceAttributes,ScopeName,ScopeVersion,SpanAttributes,Duration(nanoseconds),StatusCode,StatusMessage, and the listsEvents.Timestamp,Events.Name,Events.Attributes,Links.TraceId,Links.SpanId,Links.TraceState,Links.Attributes.SpanKindandStatusCodeare stored as words, for exampleServerandError.- Every metric table:
ServiceName,MetricName,MetricDescription,MetricUnit,Attributes(the labels, a map),ResourceAttributes,ScopeName,ScopeVersion,ScopeAttributes,ScopeDroppedAttrCount,ResourceSchemaUrl,ScopeSchemaUrl,StartTimeUnix,TimeUnix,Flags. Gauge, sum, histogram and exponential histogram also haveExemplars.FilteredAttributes,Exemplars.TimeUnix,Exemplars.Value,Exemplars.SpanIdandExemplars.TraceId. Then, per table: gauge and sum haveValue(sum alsoAggregationTemporalityandIsMonotonic); histogram hasCount,Sum,BucketCounts,ExplicitBounds,Min,Max,AggregationTemporality; exponential histogram hasCount,Sum,Scale,ZeroCount,PositiveOffset,PositiveBucketCounts,NegativeOffset,NegativeBucketCounts,Min,Max,AggregationTemporality; summary hasCount,SumandValueAtQuantiles.Quantile,ValueAtQuantiles.Value. - Every table also has
_ingested: the moment plycdn received the row, which is not always the moment it describes.SELECT *also returns a few internal columns (org,retention_days); ignore them.
Attribute maps are read with brackets: LogAttributes['order'], ResourceAttributes['k8s.namespace.name'].
Some starting points:
-- The slowest operations of the last hour
SELECT SpanName, quantile(0.95)(Duration) / 1e6 AS p95_ms, count() AS calls
FROM obs.spans WHERE Timestamp >= {from:DateTime64(3)} AND Timestamp < {to:DateTime64(3)}
GROUP BY SpanName ORDER BY p95_ms DESC LIMIT 20
-- Requests per minute, from a counter
SELECT toStartOfMinute(TimeUnix) AS minute, sum(Value) AS total
FROM obs.metrics_sum WHERE MetricName = {metric:String} AND TimeUnix >= {from:DateTime64(3)} AND TimeUnix < {to:DateTime64(3)}
GROUP BY minute ORDER BY minute
-- Logs for one order
SELECT Timestamp, ServiceName, Body FROM obs.logs
WHERE Timestamp >= {from:DateTime64(3)} AND Timestamp < {to:DateTime64(3)} AND LogAttributes['order'] = {order:String}
ORDER BY Timestamp
Useful functions
| To | Write | Example |
|---|---|---|
| Count rows | count(), countIf(condition) |
countIf(SeverityNumber >= 17) counts errors |
| Count distinct values | uniq(column) (fast, approximate), uniqExact(column) |
uniq(TraceId) |
| Percentiles | quantile(0.95)(column), avg, min, max, sum |
quantile(0.99)(Duration) / 1e6 is p99 in milliseconds |
| Group by time | toStartOfMinute(ts), toStartOfHour(ts), toStartOfDay(ts) |
GROUP BY toStartOfHour(Timestamp) |
| Relative time | now() and intervals |
Timestamp >= now() - INTERVAL 15 MINUTE |
| Search text | ILIKE (any capitals), LIKE, match(column, 'regular expression') |
Body ILIKE {pattern:String} with "%timeout%" in params |
| Read JSON inside a text | JSONExtractString(Body, 'user'), JSONExtractInt, JSONExtractFloat |
JSONExtractString(Body, 'order') |
| Read a map | Map['key'], mapKeys(Map), mapContains(Map, 'key') |
mapKeys(LogAttributes); LogAttributes['order'] |
| See a table's columns | DESCRIBE obs.logs |
DESCRIBE obs.spans |
DESCRIBE obs.logs is also the quick way to see every column of a table, including the ones this page does not list.
7Exporting
POST /api/content/v1/observability/export takes the same body as a query, plus format: ndjson (one JSON object
per line, the default) or parquet. The rows stream to you as they are produced, with no row limit.
POST /api/content/v1/observability/export
{ "sql": "SELECT * FROM obs.logs WHERE Timestamp >= {from:DateTime64(3)} AND Timestamp < {to:DateTime64(3)}",
"from": "2026-10-01T00:00:00Z", "to": "2026-10-02T00:00:00Z", "format": "parquet" }
- An export is limited to 10 minutes and 10 GB, and two run at a time per organization (a third is
export_busy, 429, retryable). It is saved with a name liketelemetry-20261016T093000Z.parquet. - A refusal (a statement
query_invalid,query_too_expensive) comes before the first byte, as an ordinary error. - A download that is cut part-way is incomplete, never a shorter file that looks whole. If something fails after the download started, the connection is dropped instead of ending cleanly, so your client sees an incomplete transfer. The server packages raise an error when that happens. Treat the file as incomplete, throw it away and run the export again.
8Following logs live
GET /api/content/v1/observability/logs/tail is a stream of server-sent events: your logs as they arrive.
GET /api/content/v1/observability/logs/tail?service=checkout&severity=17&contains=declined
Accept: text/event-stream
| Query | Meaning |
|---|---|
service |
Only this service's logs |
severity |
This severity number (1 to 24) and above: 9 is info, 13 warning, 17 error |
contains |
Only logs whose text contains these words (up to 8, any case, whole words) |
cursor |
Carry on after the cursor of the last event you handled |
: open
event: logs
data: {"rows":[{"timestamp":"2026-10-16T09:41:07.120Z","service":"checkout","severity":"ERROR","severityNumber":17,
"body":"payment declined","traceId":"","spanId":"","attributes":{"order":"A-1042"},"resource":{}}],"cursor":"1760607667120"}
: heartbeat
event: end
data: {"cursor":"1760607967120"}
- The tail runs about 8 seconds behind real time, so a row has settled before it is sent. Without a
cursorit starts one minute back. - It ends by itself after 300 seconds with an
endevent that carries acursor. Call again with that cursor to carry on without a gap or a repeat. If the stream ends without anendevent (a dropped connection), resume from the cursor of the last event you received. - A comment line (
: heartbeat) arrives about every 15 seconds while nothing else does. - A log stamped more than a day before it arrives does not appear in the tail. It is stored and you can query it.
- A failure after the stream started comes as
event: errorwith the same body as an error response, then the stream ends. Before the first event, a refusal is an ordinary error response. - Four tails can run at once per organization (a fifth is
tail_busy, 429, retryable, withRetry-After).
9One trace
GET /api/content/v1/observability/traces/{traceId} answers every span of one trace, oldest first. The trace id is 32
lower-case hexadecimal characters; an unknown one is trace_not_found (404).
{
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spans": [{
"start": "2026-10-16T09:41:07.100Z", "spanId": "00f067aa0ba902b7", "parentSpanId": "",
"name": "POST /checkout", "kind": "Server", "service": "checkout",
"durationNs": "182000000", "statusCode": "Error", "statusMessage": "payment declined",
"attributes": {}, "resource": {}, "events": [], "links": []
}]
}
durationNs is a string, because it is a 64-bit number. A trace has at most 10,000 spans in the answer. To look a
trace up from a log line, use the log's TraceId.
10Archiving to your own bucket
Name one of your active file buckets, in the same region, as archiveBucketId and plycdn writes the previous day's
data into it every night, one Parquet file for each kind of data:
observability/logs/2026/10/16/logs-20261016.parquet
observability/spans/2026/10/16/spans-20261016.parquet
observability/metrics_gauge/2026/10/16/metrics_gauge-20261016.parquet
(logs, spans, metrics_gauge, metrics_sum, metrics_histogram, metrics_exponential_histogram,
metrics_summary; a day with no data of a kind has no file for it.)
- The files are ordinary files in your bucket: you can list them, download them, and read them with any tool that reads Parquet. They count as stored data in that bucket and appear in its usage, like anything else you keep there. Deleting them is your decision; the archive does not delete anything.
- Days are UTC days. The first file covers the whole UTC day on which you chose the bucket, and is written the following night. Earlier days are not archived.
- If the bucket is not usable on a night (deleted, not active, not in the region, or your plan no longer includes storage), the following nights try again, up to seven days back. After that, that day stays unwritten: use an export if you need it.
- Each file written is recorded in your audit log as
observability.archive_written. - The archive is a copy. Data is still deleted from plycdn when its retention ends.
11Volumes, and what is billed
GET /api/content/v1/observability/volumes?from=2026-10-01&to=2026-10-16 answers, for each day, what was received,
what was being kept and what your reads scanned. The default is the last 30 days; the range is at most 400 days.
{ "retentionDays": 30,
"days": [{ "day": "2026-10-16", "rows": 1204331, "ingestedGb": 0.84, "retainedGb": 12.6, "scannedGb": 0.31 }] }
Three quantities are metered and shown on your usage and your monthly statement, per region:
| Quantity | Meter | What it counts |
|---|---|---|
| Data received | telemetry_ingest_gb |
The uncompressed bytes of the rows plycdn stored from what you sent, in GB |
| Data kept | telemetry_stored_gb_months |
The GB of data held within your retention, over time, in GB-months |
| Data read | telemetry_scanned_gb |
The bytes your queries, exports and live tails scanned, in GB. A query that runs out of time still counts for what it scanned |
You see quantities, never costs: the price of each is on your price list or in your contract, and your statement shows quantity, rate and amount like any other line. Narrower time ranges and filters read less.
12One sub-organization per customer of yours
If you run a product for customers of your own, give each one a sub-organization (see Managing
sub-organizations). Each keeps its own telemetry: its own region,
retention, ingest keys, archive bucket and rows, never visible to the others. One admin key of your account covers
them all; X-Organization-Id chooses which one a call is for.
# 1. Create the sub-organization (X-Organization-Id is your own account's id here). Copy "id" from the answer.
curl -X POST https://api.plycdn.com/api/sub-organizations \
-H "Authorization: ApiKey $PLYCDN_ADMIN_KEY" -H "X-Organization-Id: $PLYCDN_ORG" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme", "contact_email": "[email protected]" }'
# 2. Switch it on, and 3. make its key: the same calls as the quick start, with the sub-organization's id.
export SUB_ORG=ID_FROM_STEP_1 # replace
curl -X PATCH $API/observability/settings \
-H "Authorization: ApiKey $PLYCDN_ADMIN_KEY" -H "X-Organization-Id: $SUB_ORG" \
-H "Content-Type: application/json" -d '{ "region": "mumbai", "retentionDays": 30 }'
curl -X POST $API/observability/ingest-keys \
-H "Authorization: ApiKey $PLYCDN_ADMIN_KEY" -H "X-Organization-Id: $SUB_ORG" \
-H "Content-Type: application/json" -d '{ "name": "acme-production" }'
Give that customer's systems the key from step 3: everything they send lands in their organization, and your queries
with X-Organization-Id: $SUB_ORG read only their rows. Limits apply to each organization on its own: 20 active
ingest keys, and at the same time 4 live tails, 2 exports and 8 statements. The plan's retention
ceiling is your account's. Data received, kept and read are counted for each organization and appear in the usage
and on the statement by organization.
13Data protection
- For the telemetry you send, you are the controller and plycdn processes it for you, only on your instruction. Do not send personal data you do not need: log lines are kept as they are sent.
- Your data is stored in the region you chose and stays there. the data you send is stored without the address it was
sent from. Your queries, exports and tails pass through the API at
api.plycdn.com, which runs in Mumbai, and return your data to your server. - Rows are visible only to your organization: every statement runs as your organization, and cannot name another's data.
- Data is deleted when its retention ends, or sooner if you shorten the retention.
14From your server
Get the packages. In the dashboard open Developers → SDKs and download Storage & video for your language,
version 1.14.0 or later: .NET (Plycdn.AspNetCore.Storage), Node.js (@plycdn/node) or Python
(plycdn). Install them as the guide for your language says (ASP.NET Core,
Node.js, Python); the observability calls are in the same package as storage, and
need an admin API key. Sending data needs none of them.
The packages call all ten routes with the same names in each language. A quick tour:
// .NET (Plycdn.AspNetCore.Storage)
var result = await observability.QueryTelemetryAsync(orgId, new TelemetryQuery {
Sql = "SELECT count() FROM obs.logs WHERE ServiceName = {svc:String}", Params = new Dictionary<string, object> { ["svc"] = "api" } });
await foreach (var ev in observability.TailLogsAsync(orgId, new LogTailOptions { Service = "api" })) { /* ev.Kind, ev.Rows, ev.Cursor */ }
var key = await observability.CreateIngestKeyAsync(orgId, new IngestKeyCreate("api")); // key.Key is shown once
// Node.js (@plycdn/node/observability)
const result = await observability.queryTelemetry(orgId, { sql: 'SELECT count() FROM obs.logs WHERE ServiceName = {svc:String}', params: { svc: 'api' } });
for await (const ev of observability.tailLogs(orgId, { service: 'api' })) { /* ev.kind, ev.rows, ev.cursor */ }
const key = await observability.createIngestKey(orgId, { name: 'api' }); // key.key is shown once
# Python (plycdn.observability)
result = observability.query_telemetry(org_id, {"sql": "SELECT count() FROM obs.logs WHERE ServiceName = {svc:String}", "params": {"svc": "api"}})
for event in observability.tail_logs(org_id, service="api"): # event["kind"], event["rows"], event["cursor"]
...
key = observability.create_ingest_key(org_id, {"name": "api"}) # key["key"] is shown once
Downloads (ExportTelemetryAsync, exportTelemetry, export_telemetry) hand you a stream, and a download that
was cut raises an error rather than ending quietly. The tail ends with an end event after about five minutes: call it
again with that event's cursor. The per-language guides have the rest: ASP.NET Core,
Node.js, Python.
15Errors
| Code | Status | What to do |
|---|---|---|
observability_not_enabled |
409 | Choose a region first: PATCH /observability/settings with a region |
observability_region_fixed |
409 | The region cannot change once chosen |
region_not_available |
400 | That region does not offer observability yet. Mumbai does |
validation_error |
422 | The from/to range of the volumes is backwards or longer than 400 days |
observability_unavailable |
503 | Retryable. Observability could not answer or take the data just then. Try the same request again shortly. A settings change answered this way was saved |
query_invalid |
400 | The statement is not one read-only statement over your tables, or the SQL has a mistake. The detail says what |
query_too_expensive |
400 | The statement would read or return too much. Narrow the time range or add filters |
query_timeout |
400 | The statement took longer than 30 seconds. Narrow it, or export instead |
trace_not_found |
404 | No such trace in your data |
tail_busy |
429 | Retryable. Four tails are already running. Wait for Retry-After |
export_busy |
429 | Retryable. Two exports are already running |
ingest_key_limit_reached |
409 | Twenty keys are active. Revoke one |
ingest_key_not_found |
404 | Unknown key id, or another organization's |
archive_bucket_invalid |
400 | The archive bucket must be one of your active file buckets in the same region |
plan_limit_reached |
403 | The retention you asked for is longer than your plan allows |
not_included_in_plan |
403 | Your plan does not include observability |
admin_key_required |
403 | These calls need an admin API key |
The codes you meet while sending (ingest_key_invalid, ingest_wrong_region, ingest_invalid, ingest_too_large,
rate_limited) are in Rate and limits when sending; every code is also in the
API reference.