# Log drains and log forwarding

Get a platform's logs into a Bucker project without touching your code, or forward logs you
already collect. Everything here ends in the same place as an SDK log: the project's **Logs**
(Explore's *Logs* dataset), with the same PII scrubbing, sampling rules, quota and drop
accounting. A log line that carries an exception (`exception.type`, `exception.message`)
opens or updates an issue.

There are two ways in.

| You have | Use |
| --- | --- |
| Vercel, Cloudflare, Heroku or Supabase | A **platform drain**: paste a URL into the platform ([below](#platform-drains)) |
| A Collector, Fluent Bit, Vector or any OTLP exporter | **OTLP**: point it at `/v1/logs` ([below](#forwarding-with-a-collector-fluent-bit-or-vector)) |

## Platform drains

Open **Settings, Projects**, and press **Log drains** beside the DSN you want to use. The dialog
shows one URL per platform and, for Vercel, a signing secret. It needs write access to the
project, because the secret is not public. The URLs carry the DSN's public key, so disabling or
revoking that key stops the drain, and a drain has the DSN's rate limit.

| Platform | Route | Credential | Format |
| --- | --- | --- | --- |
| Vercel | `POST /drains/vercel/<projectId>` | `?sentry_key=`, or a header | JSON array or NDJSON, optionally gzipped, signed |
| Cloudflare Logpush | `POST /drains/cloudflare/<projectId>` | `?sentry_key=`, or a header | NDJSON, usually gzipped |
| Heroku | `POST /drains/heroku/<projectId>` | HTTP Basic (`https://<key>@host/…`), or `?sentry_key=` | `application/logplex-1` syslog frames |
| Supabase | `POST /drains/supabase/<projectId>` | a header (recommended), or `?sentry_key=` | JSON array of events, usually gzipped |

The credential can also be sent as `X-Sentry-Auth: Sentry sentry_key=<key>`, which is what to
use where a platform's form takes headers but a URL with a key in it is awkward to keep.

### Vercel

Drains are a **Pro or Enterprise** feature. In the Vercel dashboard open **Team Settings,
Drains**, press **Add Drain**, choose the **Logs** data type, then **Custom endpoint**:

1. Pick the projects, sources (build, lambda, edge, static, external, firewall, redirect) and
   environments to send, and the sampling rate if you want one. Vercel applies these before
   sending, so Bucker only ever sees what you selected.
2. Paste the Vercel URL as the endpoint and choose the **JSON** or **NDJSON** encoding. Gzip
   compression may be on or off.
3. **Replace the signature secret.** Vercel generates a secret for every drain and signs
   every delivery with it (`x-vercel-signature`, HMAC-SHA1 of the body). Bucker checks that
   signature against the signing secret its dialog shows, so overwrite Vercel's generated
   value with Bucker's **before** you save or press **Test**. If you leave Vercel's own secret
   in place, every delivery, including the create-time test, is refused with
   `403 invalid_signature`.
4. Save. Vercel tests the endpoint with sample events when the drain is created, and again
   when you press **Test**; both should pass with a 200.

A request that carries no signature is still accepted, because the key in the URL is the
credential, as for every other ingest door. The signature proves a delivery came from your
Vercel drain; it does not replace the key. The secret is derived from the key, so it changes if
you rotate the key, and the drain must then be given the new one.

Older, project-level log drains (the deprecated `/v1/log-drains` API) showed a verification
code and expected the endpoint to echo it. If you see one, append `&verify=<code>` to the URL
and Bucker echoes it in the `x-vercel-verify` response header. The current Drains flow does
not ask for this.

What is kept from each entry: its level and message, the deployment, project, build and
request ids (`vercel.deployment.id`, `vercel.project.id`, `vercel.build.id`,
`vercel.request.id`), the requested path without its query string (`url.path`), the
function or route pattern (`http.route`, for example `/api/orders/[id]` or `middleware`), the
method and status, the edge and execution regions, the cache and firewall outcome, the user
agent, and the `traceId`/`spanId` Vercel adds to logs written during a traced request, so a log
links to its trace. The client IP, the referer and the TLS fingerprints are not kept. A request
line with no message becomes `GET /path 200`, at a level taken from the status; a firewall
line adds its action, such as `(firewall: deny)`.

- A function that **crashed** (`statusCode: -1`) is an error, marked `vercel.function_crashed`.
  A **background revalidation** (`proxy.statusCode: -1`) is marked `vercel.revalidated`.
  Neither is stored as an HTTP status.
- A **custom environment** (for example `staging`) is the record's environment.
- Entries from the Test button, and older deliveries, carry no `level`; the line's stream
  (`stderr` is an error) and then its status set the level.
- Vercel allows messages up to 256 KB; Bucker keeps the first 8,192 characters and records the
  original length as `log.body.original_length`.
- Errors are error-level **log records**. The Vercel mapper does not open issues.
- Vercel does not document retries. A delivery Bucker refuses is likely lost, and enough
  refusals mark the drain as errored in Vercel, which emails the team.

### Cloudflare Logpush

Create a Logpush job (dashboard: **Analytics & Logs, Logpush**; or the Logpush API) with an
**HTTP destination**, and paste the Cloudflare URL as the destination. Logpush signs nothing,
so the key in the URL is the credential. To send it as a header instead, put a `header_`
parameter in the destination. Logpush turns it into a request header and needs the value
URL-encoded:

```
https://<region>.<ingest-host>/drains/cloudflare/<projectId>?header_X-Sentry-Auth=Sentry%20sentry_key%3D<publicKey>
```

Leave the job's **output options** at their defaults: NDJSON, no CSV, no custom record
template. A job that wraps its lines with `batch_prefix`/`batch_suffix` into one
`{"events":[...]}` object is also read. For timestamps, choose `rfc3339ms` or `unixnano`. The
dashboard default, `rfc3339`, has whole-second resolution, so a second's requests share one
timestamp.

**Set the batch limits.** Logpush's defaults (up to 100,000 records per upload) are above what
one Bucker request takes: at most 50,000 log records, and 16 MiB decompressed. A Workers line
that logged several times counts once per console line. With the API, set
`max_upload_records` to 10,000 and `max_upload_bytes` to 5,000,000, the smallest Logpush
allows. A refused upload is retried by Logpush until its data ages out, and is then dropped.

When the job is created, Logpush checks the destination by uploading a small gzipped test
file; Bucker accepts it and stores nothing. Two datasets are understood in depth:

- **Workers trace events**: each `console.*` line is a record at its level. Each uncaught
  exception is an error record with `exception.type` and `exception.message`, so it opens an
  issue. An invocation that logged nothing but did not end `ok` is still recorded: an error
  for `exception` (or a CPU or memory limit), a warning for `canceled` or `unknown`. The Ray
  ID, the event type (`fetch`, `scheduled`, `queue`, ...), CPU and wall time are attributes,
  the request URL is split into `url.full`, `server.address` and `url.path` without its query
  string, and the Worker's `ScriptVersion` becomes the record's release.
- **HTTP requests** (and other request-shaped datasets): one record per line,
  `METHOD host/path -> status`, at a level taken from the status, with the Ray ID, zone, colo,
  cache status and response size. The client IP and referer are not kept.

Other datasets are stored as one record per line, with the line (truncated, and without its
client IP, referer, headers or cookies) as the body. Their time field is read where Cloudflare
documents one (`Datetime`, `Timestamp`, `When`, `CreatedAt`, ...).

Cloudflare can also export Workers logs and traces as OpenTelemetry
([OpenTelemetry export](https://developers.cloudflare.com/observability/export/opentelemetry/)).
That path sends OTLP/JSON with custom headers, and goes to Bucker's OTLP door (`/v1/logs`,
[below](#forwarding-with-a-collector-fluent-bit-or-vector)) rather than a drain URL.

### Heroku

HTTPS drains exist on **Cedar** apps:

```
heroku drains:add "https://<publicKey>@<region>.<ingest-host>/drains/heroku/<projectId>?service=my-app" -a my-app
```

There is no verification step: Logplex starts POSTing as soon as the drain is added. Heroku
log lines do not carry the app name, so `?service=` names the service; the drain's id
(`Logplex-Drain-Token`) is kept as `heroku.drain.id`, which tells two apps on one URL apart.
Router lines are parsed: `at=error` (with its `code`, such as `H12`, and `desc`), 5xx statuses
and platform errors such as `R14` or `L10` are errors, and the status, method, path (without
its query string), host, bytes, protocol, TLS version, dyno and connect and service times are
attributes. The router's `fwd` field (the client IP) and the path's query string are removed
from the stored line.

Heroku sends application output at the same syslog level whether it went to stdout or stderr,
and writes its own dyno lifecycle lines at an alarming but meaningless level. So the level is
read from the line itself where it says one: `ERROR`, `[warn]`, `level=error`,
`"level":"error"` and Ruby's `E, [...] ERROR --` all raise a line. Everything else is info.

Logplex retries a request that got a 429, a 5xx or no answer within 5 seconds, with the same
`Logplex-Frame-Id`, and Bucker does not write the same frame id twice. It drops a batch for
good on any other 4xx. Lines over 10,000 bytes arrive as several records. Frames are read by
their byte counts, so a line that is not valid UTF-8 is stored with replacement characters
and does not cost the rest of its batch.

**Fir** apps have no HTTPS drains. They use telemetry drains, which speak OTLP, so point one at
Bucker's OTLP door with the key in a header:

```
heroku telemetry:add "https://<region>.<ingest-host>" --app my-app --signals logs --transport http --headers '{"x-sentry-auth":"Sentry sentry_key=<publicKey>"}'
```

Heroku's documentation does not say whether the telemetry collector appends `/v1/logs` to the
URL itself; if no logs arrive, give it `https://<region>.<ingest-host>/v1/logs`.

### Supabase

Log drains are on the **Pro, Team and Enterprise** plans. Open **Project Settings, Log
Drains**, add a destination of type **Custom Endpoint**, and paste the Supabase URL **without**
its `?sentry_key=` part. Put the key in a header instead, because Supabase shows the URL in
plain text but redacts header values:

- **Headers**: `X-Sentry-Auth` = `Sentry sentry_key=<publicKey>`
- **Gzip**: either; **HTTP version**: either.

Supabase tests the endpoint with an empty batch when you save; a wrong key fails that test.

One drain carries every Supabase service, a batch at a time, and nothing in a request says
which service sent it. So Bucker works it out per line from the line's metadata, and each
service becomes its own `service.name`: `supabase-edge` (the API gateway), `supabase-postgres`,
`supabase-auth`, `supabase-function` and `supabase-function_edge` (Edge Function logs and
invocations), `supabase-realtime`, `supabase-storage`, and `supabase-other` for PostgREST and
PgBouncer, which carry nothing to tell them apart. `supabase.source` holds the same name.
`&source=<name>` on the URL forces one label onto every line instead.

Supabase timestamps are microseconds; they are read as such. The log's metadata is searched
for the request method, path and status, the Postgres `error_severity` and `sql_state_code`,
and a service's own level, and is **never stored whole**: gateway metadata includes request
headers and token claims. The stored body is the line's message, with the client IP and URL
query strings removed from gateway and function lines and `remote_addr` from Auth lines.

Supabase does **not retry** a delivery that fails, so a refused request is lost. It can send
one request per service per second, about 540 a minute for a busy project, which is above a
key's default rate limit of 500 requests a minute. Give the drain a key of its own and raise
that key's `rateLimitPerMinute` (`PATCH /orgs/<org>/projects/<project>/keys/<keyId>`) so the
drain is not throttled and the SDKs are not throttled by it.

Supabase also has a native **OpenTelemetry** destination. It labels every line with its
service and sends OTLP to Bucker's OTLP door (`/v1/logs`) with a header key.

### Behaviour that applies to every drain

- **A 400 means the platform is sending the wrong format**, for example a Vercel drain set to
  a format Bucker cannot read, or a Logpush job with CSV output. The message says what was
  expected, and the drop is recorded on the project's drops panel as an invalid body. A body
  that is simply empty is a 200.
- **What happens after a refusal is the platform's choice.** Cloudflare Logpush retries until
  the data ages out. Heroku retries a 429, a 5xx or a timeout, and drops anything else. Vercel
  documents no retry, and Supabase does not retry. Within one request, records are written in
  chunks, so a request refused part-way (for example by quota) can leave its earlier chunks
  stored, and a platform that retries it delivers those again.
- **Limits and accounting are the OTLP logs door's.** A drain record is metered as a log
  record, sampled by the project's sampling rules, scrubbed at the stored tier, and counted in
  the usage and drops panels.
- **A 401 or 403** is the key: unknown, disabled, or for another project. A suspended project
  is refused the way it is for SDKs. On Vercel, a 403 `invalid_signature` is the signing
  secret.
- **Every record carries its batch's `service.name` and `cloud.provider`**, so Explore can group
  drain logs by service.

### Verified against

The payload shapes, transports and setup steps above were checked on 2026-10-17 against each
platform's documentation and public example payloads. Every example is kept as a test fixture
with its source (`code/api/test/log-drains/fixtures/`):

- Vercel: [Drains logs reference](https://vercel.com/docs/drains/reference/logs),
  [Using Drains](https://vercel.com/docs/drains/using-drains),
  [Drains security](https://vercel.com/docs/drains/security),
  [Create a drain (API)](https://vercel.com/docs/rest-api/drains/create-a-new-drain).
- Cloudflare: [Logpush HTTP destination](https://developers.cloudflare.com/logs/logpush/logpush-job/enable-destinations/http/),
  [workers_trace_events](https://developers.cloudflare.com/logs/logpush/logpush-job/datasets/account/workers_trace_events/),
  [http_requests](https://developers.cloudflare.com/logs/logpush/logpush-job/datasets/zone/http_requests/),
  [Log output options](https://developers.cloudflare.com/logs/logpush/logpush-job/log-output-options/),
  [Workers Logpush](https://developers.cloudflare.com/workers/observability/logs/logpush/).
- Heroku: [Log drains](https://devcenter.heroku.com/articles/log-drains),
  [HTTP routing](https://devcenter.heroku.com/articles/http-routing),
  [Logging](https://devcenter.heroku.com/articles/logging),
  and Logplex's own [HTTP drain source](https://github.com/heroku/logplex/blob/master/src/logplex_http_drain.erl).
- Supabase: [Log drains](https://supabase.com/docs/guides/telemetry/log-drains), and the
  sender, Logflare's [webhook adaptor](https://github.com/Logflare/logflare/blob/031ffc5c3d81a9f6df8d008db29335d83ac1bce3/lib/logflare/backends/adaptor/webhook_adaptor.ex).

A live test against each platform has not been run yet. Until it has, treat the details
above that the platforms do not document (whether Vercel signs a gzipped body before or after
compression, Logpush's exact headers, Supabase's retry behaviour) as checked against their
published code and examples, not against a real delivery.

## Forwarding with a Collector, Fluent Bit or Vector

Anything that can speak OTLP/HTTP can send logs to Bucker. See [OTLP ingest](otlp.md) for the
endpoint, authentication, encodings and limits. In each example the DSN is the whole credential.

### OpenTelemetry Collector

```yaml
receivers:
  filelog:
    include: [/var/log/app/*.log]
  otlp:
    protocols: { http: {} }

processors:
  batch:
    send_batch_size: 1000
    timeout: 5s

exporters:
  otlphttp/bucker:
    endpoint: https://<region>.<ingest-host>
    headers:
      Authorization: "DSN https://<publicKey>@<region>.<ingest-host>/<projectId>"
    encoding: proto
    compression: gzip

service:
  pipelines:
    logs:
      receivers: [filelog, otlp]
      processors: [batch]
      exporters: [otlphttp/bucker]
```

The exporter appends `/v1/logs` itself. Set `service.name` and `deployment.environment.name`
with a `resource` processor if the source does not: they become the log's service and
environment.

### Fluent Bit

```ini
[SERVICE]
    Flush        5

[INPUT]
    Name         tail
    Path         /var/log/app/*.log
    Tag          app

[OUTPUT]
    Name         opentelemetry
    Match        *
    Host         <region>.<ingest-host>
    Port         443
    Logs_uri     /v1/logs
    Tls          On
    Header       Authorization DSN https://<publicKey>@<region>.<ingest-host>/<projectId>
    Compress     gzip
    logs_body_key message
```

`logs_body_key` names the record field that becomes the log body; the other fields become
attributes. Add `add_label` lines (or a `record_modifier` filter) for `service.name`.

### Vector

```toml
[sinks.bucker]
type = "opentelemetry"
inputs = ["app_logs"]

[sinks.bucker.protocol]
type = "http"
uri = "https://<region>.<ingest-host>/v1/logs"
method = "post"
encoding.codec = "json"
framing.method = "newline_delimited"

[sinks.bucker.protocol.request.headers]
Authorization = "DSN https://<publicKey>@<region>.<ingest-host>/<projectId>"
Content-Type = "application/json"
```

Shape events into OTLP with a `remap` transform that builds `resourceLogs` (Vector's
`opentelemetry` source/sink handle this when the events came from one). Check `Content-Type`:
Bucker reads `application/json` and `application/x-protobuf`.

### Checking it works

Send one line and read it back in Explore, dataset **Logs**, with `service.name` as the group.
If nothing arrives: a `401`/`403` in the forwarder's log is the key, a `400` names the
problem in its message, and **Settings, Projects, Drops** shows anything Bucker refused after
accepting the request.
