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:
- 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.
- Paste the Vercel URL as the endpoint and choose the JSON or NDJSON encoding. Gzip compression may be on or off.
- 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 with403 invalid_signature. - 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, markedvercel.function_crashed. A background revalidation (proxy.statusCode: -1) is markedvercel.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 (stderris 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 withexception.typeandexception.message, so it opens an issue. An invocation that logged nothing but did not endokis still recorded: an error forexception(or a CPU or memory limit), a warning forcanceledorunknown. The Ray ID, the event type (fetch,scheduled,queue, ...), CPU and wall time are attributes, the request URL is split intourl.full,server.addressandurl.pathwithout its query string, and the Worker'sScriptVersionbecomes 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_signatureis the signing secret. - Every record carries its batch's
service.nameandcloud.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, Using Drains, Drains security, Create a drain (API).
- Cloudflare: Logpush HTTP destination, workers_trace_events, http_requests, Log output options, Workers Logpush.
- Heroku: Log drains, HTTP routing, Logging, and Logplex's own HTTP drain source.
- Supabase: Log drains, and the sender, Logflare's webhook adaptor.
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.