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 /plan lists observability.
  • 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)

  1. Observability → Settings. Choose a region and press Switch on. The page then shows Send your first data, with every address filled in.
  2. 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.
  3. 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": null to 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-keys lists 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_created and observability.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 plycdn with a key parameter: [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 params and 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 or true/false, up to 4,096 characters; up to 32 parameters.
  • from and to (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 your WHERE, 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 with BigInt, long.Parse or int().
  • Read only. One statement per call: SELECT, or WITH ... SELECT, DESCRIBE or EXPLAIN. A second statement, anything that changes data, and anything that reads from outside your own tables is refused with query_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 is query_timeout (400). Reads are also capped per hour for your organization: after several heavy statements, a query_too_expensive can 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 lists Events.Timestamp, Events.Name, Events.Attributes, Links.TraceId, Links.SpanId, Links.TraceState, Links.Attributes. SpanKind and StatusCode are stored as words, for example Server and Error.
  • 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 have Exemplars.FilteredAttributes, Exemplars.TimeUnix, Exemplars.Value, Exemplars.SpanId and Exemplars.TraceId. Then, per table: gauge and sum have Value (sum also AggregationTemporality and IsMonotonic); histogram has Count, Sum, BucketCounts, ExplicitBounds, Min, Max, AggregationTemporality; exponential histogram has Count, Sum, Scale, ZeroCount, PositiveOffset, PositiveBucketCounts, NegativeOffset, NegativeBucketCounts, Min, Max, AggregationTemporality; summary has Count, Sum and ValueAtQuantiles.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 like telemetry-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 cursor it starts one minute back.
  • It ends by itself after 300 seconds with an end event that carries a cursor. Call again with that cursor to carry on without a gap or a repeat. If the stream ends without an end event (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: error with 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, with Retry-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.