Skip to content
bucker

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)
A Collector, Fluent Bit, Vector or any OTLP exporter OTLP: point it at /v1/logs (below)

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). That path sends OTLP/JSON with custom headers, and goes to Bucker's OTLP door (/v1/logs, below) 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/):

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 for the endpoint, authentication, encodings and limits. In each example the DSN is the whole credential.

OpenTelemetry Collector

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

[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

[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.