Skip to content

Log drains

A log drain sends an app’s logs to where you keep the rest of yours: what its code prints and throws, and a container’s output. Lines go in batches, about a minute behind live. A drain can send to:

  • HTTPS, a URL of yours that takes each batch as signed JSON lines. Use it for Elastic, Sumo Logic, Axiom, Better Stack, Vector or a collector of your own.
  • Datadog, through its log intake, on your Datadog site.
  • Grafana Loki, on Grafana Cloud or your own.
  • An S3 bucket: Cloudflare R2, AWS S3, or another that speaks S3.

A drain is for one app, or for every app in a workspace, those added later included. A deleted app’s lines aren’t read while it’s kept, and what its own drains still hold waits with them. Its drains go on once it’s restored.

Drains are on Team, Business and Enterprise. A workspace can have 2 on Team and 5 on Business and Enterprise, app drains and workspace drains together. On Free, adding one is refused with 403 plan_limit.

A workspace’s drains send 100 MB of log lines a day between them, counted as the JSON lines below, once for each drain a line goes to. Past it, their lines are left out until midnight UTC, and counted, as below says.

  • A drain for one app: whoever can share the app, meaning its owners and the workspace’s owners and admins.
  • A drain for every app: the workspace’s owners.

Anyone who can edit an app sees the drains that send its logs, as they can read the logs. The workspace’s owners and admins see every drain on the workspace’s Log drains page.

Every owner of the workspace gets an email when a drain is added or turned back on, saying who did it, which apps it sends and where. The change is in the audit log, so the workspace’s webhooks and Slack channels hear of it too.

In the dashboard, open the app’s Log drains tab, or the workspace’s Log drains page for every app, and select Add drain. Choose where it sends, and fill in where and how to sign in there. If you signed in more than 15 minutes ago, we ask you to sign in again first.

From the CLI, give workspace/app for one app or workspace for every app. The credential is asked for at the prompt, or read from stdin, so it stays out of your shell history:

Terminal window
jiayang log-drain add acme/payroll --http https://logs.example.com/jiayang --header Authorization
jiayang log-drain add acme --datadog datadoghq.eu --tags env:prod
jiayang log-drain add acme/payroll --loki https://logs-prod-006.grafana.net --loki-user 123456
jiayang log-drain add acme --s3 acme-logs --region us-east-1 --access-key-id AKIAIOSFODNN7EXAMPLE --prefix apps/

Add --level warn to send only warnings and errors. The levels are debug (every line, the default), info, warn and error, as jiayang logs --level reads them. A drain sends what’s written from the moment it’s added. Send a test line to check it:

Terminal window
jiayang log-drain test acme <id>

We keep every credential encrypted in our control plane, and never show it again, to you or in the log. Your app’s code never sees it. The dashboard and the log show only the kind of drain and where it sends, as far as that can be shown: the domain of a URL, the Datadog site, the bucket and its prefix. To change a credential, remove the drain and add it again.

Give a URL that takes JSON lines. It has to start with https://, name a public host, and use port 443 or 8443, as a webhook’s does. If your collector wants a token, give a header to send with each batch, like Authorization, and its value.

Each batch is a POST of one line per log line, each a JSON object, with these headers:

Header What it holds
Content-Type application/x-ndjson
Jiayang-Signature t=<unix time>,v1=<signature>, as a webhook’s
Jiayang-Batch The batch’s id. The same on every try
Jiayang-Drain The drain’s id

We show the signing secret once, when the drain is made. It starts with jyw_. Check the signature as you would a webhook’s, over the body as it came.

Give your Datadog site, the one in your Datadog address: datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ap1.datadoghq.com, ap2.datadoghq.com or ddog-gov.com. And an API key, from Organization Settings, API Keys. An application key won’t work.

Each line is a log with ddsource jiayang, service set to the app’s slug, status set to its level (warn is warning), the tags workspace:<slug>, app:<slug> and source:worker or source:container, any tags you gave, the line’s text as message, its time as date, and the whole line under jiayang.

Give Loki’s address: the host alone, like https://logs-prod-006.grafana.net, and we send to /loki/api/v1/push on it, or the whole push address.

  • Grafana Cloud: give the user, the number shown as User on your Loki data source’s details, and an access policy token with the logs:write scope as its token. We sign in with basic auth.
  • Your own Loki behind a token: leave the user out and give the token. We send it as Authorization: Bearer.
  • A tenant: give it, and we send it as X-Scope-OrgID.

Each line goes in a stream labelled service_name and app (the app’s slug), workspace, level and source. The line itself is the whole of it as JSON, so | json reads its fields:

{service_name="payroll", level="error"} | json | message =~ "order.*"

Give the bucket, its region, and an access key ID and secret access key that can write to it. For AWS, leave the endpoint out. For R2, the endpoint is https://<account id>.r2.cloudflarestorage.com and the region is auto. For another store that speaks S3, give its endpoint. The key needs nothing but writing objects to the bucket, so make one that can do only that.

Each batch is one object: a gzip of one line per log line, as JSON. It’s named by the day and time the batch was made, under the prefix if you gave one:

apps/2026/10/07/20261007T140531Z_8a3c7c0e-0d4f-4e3a-9c2b-5b1f0e6d2a11.jsonl.gz

A test line goes in an object whose name has test_ before the id.

{
"id": "01K6Z8X4Q0V9T3HG2M7B5R1C6D",
"at": "2026-10-07T14:05:31.204Z",
"level": "error",
"message": "Cannot read properties of undefined (reading 'id')",
"error": null,
"trigger": "GET /pay",
"platform": false,
"source": "worker",
"app": { "id": "9f2a…", "slug": "payroll" },
"workspace": { "id": "5b1c…", "slug": "acme" }
}
Field What it is
id The line’s id. The same on every try, so skip one you already have
at When it was written, in UTC
level debug, info, log, warn or error. log is console.log, and a drain counts it as info
message What was written, cut at 8,192 characters
error An exception’s message, when the line is one
trigger The request being handled, like GET /pay, or what else started it, like cron
platform true for our own lines about a container app’s container, such as it starting
source worker, or container for a container’s output

Tokens are taken out of every line before it goes, as they are for jiayang logs: the identity token we give each request, and anything shaped like one of ours. A test line has the message A test line from Jiayang Cloud. and the source platform.

A drain reads an app’s lines a little behind live, from Cloudflare’s Workers Logs, which is where jiayang logs reads them too. It reads again a little before where it last stopped, so a line that took a while to reach Workers Logs still goes. A line more than two minutes late may not go. Each line goes once to each drain. A batch holds up to 1,000 lines and stays under a megabyte.

A batch has 30 seconds to be answered with any 2xx. When one fails, the drain waits as a whole: it tries again after a minute, then 2, 4, 8 minutes and so on, up to an hour apart, one batch at a time, the oldest first, and the rest wait their turn. Once one goes through, the rest go straight after it. If the oldest still fails after a day, we keep it as a failed batch, turn the drain off, and email the workspace’s owners, and for an app’s drain whoever it depends on.

Failed batches are kept for a week. To send them again, once the destination works:

Terminal window
jiayang log-drain failed acme <id>
jiayang log-drain retry acme <id>
jiayang log-drain resume acme <id>

retry sends every failed batch again, or one with --batch <id>. resume turns the drain back on. Lines written while a drain is off aren’t sent, so turned back on it sends from then on. The dashboard does the same with Send failed batches again and Turn on, and Recent batches lists what was sent, with how each went.

A drain that already holds 20 MB of batches, waiting or failed, takes no more lines until some go or are removed. So does every drain of a workspace whose drains have sent their 100 MB for the day, until midnight UTC. The lines they had no room for are counted, and the dashboard and jiayang log-drain list show how many were left out. So are the lines we pass over unread: those written more than 6 hours before we got to them, after an outage on our side, and the oldest of a stretch with more lines than one minute’s read takes. Those are counted from Cloudflare’s own count, by the level it kept for each line, so the figure may be a little off.

We also turn a drain off when:

  • whoever added it, or last turned it on, can no longer manage it: for an app’s drain, they can’t share the app; for every app’s, they’re no longer an owner.
  • the workspace’s plan has room for fewer drains. The newest go off first.

Each time we turn a drain off, the log has a log_drain.off entry saying why. Turned back on by someone else, it depends on them from then on, and the list still says who added it. Deleting an app for good, once it can no longer be restored, removes its drains.

A workspace’s apps log a number of lines a day by its plan, as limits says. Past it, its apps stop logging for the rest of the UTC day, and its drains have nothing to send until the next.

Terminal window
jiayang log-drain list acme
jiayang log-drain pause acme <id>
jiayang log-drain level acme <id> error
jiayang log-drain remove acme <id>

An agent can list drains and pause, resume or change one with the list_log_drains and set_log_drain tools. Adding one is for you, since it takes a credential.

A drain never sends to a private, loopback or link-local address, however the address is written, and we check the host’s addresses again every time we send. It can’t point at Jiayang Cloud itself, and it doesn’t follow a redirect. These are the same rules as for webhooks. Your app’s code takes no part: the control plane reads the lines and sends them, so a drain never needs a credential inside your app.