{"info":{"version":"1.0.0","description":"The **Funnel Ingestion API** accepts observability data — metrics, traces,\nand logs — from any OpenTelemetry-compatible client and from browser-side\nReal User Monitoring (RUM) beacons.\n\nAll data is scoped to a **project** via a Bearer API key. The same key works\nacross all three telemetry pillars.\n\nBeyond OTLP, Funnel also accepts a simplified **native JSON** schema\ndesigned for hand-rolled clients (cron jobs, shell scripts, demo apps).\n\n## Transports\n\n| Transport                     | Status   | Endpoint                          |\n|-------------------------------|----------|-----------------------------------|\n| HTTP/1.1 + JSON (OTLP-JSON)   | Stable   | `POST /v1/{metrics,traces,logs}`  |\n| HTTP/1.1 + JSON (native)      | Stable   | same                              |\n| HTTP/1.1 + Protobuf (OTLP)    | Stable   | same (Content-Type negotiated)    |\n| gRPC + Protobuf (OTLP)        | Stable   | `:4317` (opt-in, see env vars)    |\n| Browser beacon (RUM)          | Stable   | `POST /v1/rum/events`             |\n\nAll transports share the same auth model, the same pipeline, and the\nsame per-project quota/burst accounting. Pick whichever your client\nSDK supports — they're wire-equivalent.\n\n## Conventions\n\n* All timestamps accept either ISO-8601 strings or Unix nanoseconds (`timeUnixNano`).\n* All `attributes` fields are free-form JSON objects; nested objects are flattened\n  on the server using dot-notation.\n* Histogram metrics ingested via OTLP are exploded into `_count`, `_sum`, and\n  `_bucket` (with `bucket_le`) series, mirroring the Prometheus convention.\n\n## Limits\n\n* Maximum request body: **5 MB** (configurable).\n* Recommended batch size: 500 events per request.\n* Server-side flush cadence: 250 ms / 500 events, whichever comes first.\n","title":"Funnel Ingestion API","summary":"OTLP-compatible HTTP & gRPC ingestion for metrics, traces, logs, and RUM."},"components":{"responses":{"BurstRateLimited":{"description":"Burst rate limit exceeded for this project. Token-bucket scoped per project; tier-specific rate. Daily quota is NOT charged for rejected requests.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"retry_after_ms":{"type":"integer"}}},"example":{"error":"burst rate limit","retry_after_ms":1000}}},"headers":{"Retry-After":{"description":"Seconds until the bucket has enough tokens to accept again.","schema":{"type":"integer"}}}},"MissingScope":{"description":"API key is valid but does not carry the required scope for this endpoint.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"missing scope: ingest:sessions"}}}},"OtlpPartialSuccess":{"description":"OTLP-spec PartialSuccess response. Always returned for successful OTLP requests, even with zero rejections.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OtlpPartialSuccessBody"},"examples":{"full_success":{"value":{"partialSuccess":{}},"summary":"All records accepted"},"partial_success_metrics":{"value":{"partialSuccess":{"errorMessage":"2 records dropped (validation)","rejectedDataPoints":2}},"summary":"Some metric data points rejected (validation)"},"partial_success_traces":{"value":{"partialSuccess":{"errorMessage":"5 records dropped (validation)","rejectedSpans":5}},"summary":"Some spans rejected (invalid trace ID)"}}}}},"PayloadTooLarge":{"description":"Request body exceeds the configured limit (5 MB pre-decompression, 50 MB post-gzip-inflate). The second limit protects against gzip-bomb DoS.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"decompressed body exceeds 52428800 bytes (gzip bomb?)"}}}},"QuotaExceeded":{"description":"Daily ingestion quota exceeded. Resets at 00:00 UTC. Set a paid plan tier or per-project override to raise the cap.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"used":{"type":"integer"},"limit":{"type":"integer"},"kind":{"type":"string","enum":["bytes","events"]},"resets_at_utc":{"type":"string","format":"date-time"}}},"example":{"error":"quota exceeded","used":104857600,"limit":104857600,"kind":"bytes","resets_at_utc":"2026-05-18T00:00:00Z"}}},"headers":{"Retry-After":{"description":"Seconds until midnight UTC when the daily counter resets.","schema":{"type":"integer"}}}},"ServiceOverloaded":{"description":"Ingestion pipeline is shedding load — buffer is over the 10k high-water mark. Recovery uses hysteresis (clears at 5k). Retry with backoff.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"retry_after_ms":{"type":"integer"}}},"example":{"error":"service overloaded","retry_after_ms":12000}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}}},"Unauthorized":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"invalid api key"}}}},"UnprocessableEntity":{"description":"Body failed validation.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"service can't be blank"}}}}},"schemas":{"AcceptResponse":{"type":"object","required":["accepted"],"properties":{"accepted":{"type":"integer","description":"Count of decoded events."}},"example":{"accepted":25}},"AlertFiredPayload":{"type":"object","description":"Body POSTed by Funnel to user webhook URLs when an alert fires.","properties":{"message":{"type":"string"},"type":{"type":"string","enum":["alert.fired"]},"value":{"type":"number","nullable":true},"severity":{"type":"string","enum":["info","warning","critical"]},"project_id":{"type":"integer"},"rule":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"kind":{"type":"string"}}},"fired_at":{"type":"string","format":"date-time"}}},"Attributes":{"type":"object","description":"Free-form key/value attributes (strings, numbers, bools, lists).","example":{"env":"prod","service.name":"api"},"additionalProperties":true},"CostRecord":{"type":"object","description":"One daily-grain cost line item.","required":["day","cloud_provider","resource_kind","amount_cents"],"properties":{"service":{"type":"string","description":"service.name for catalog join","example":"checkout-api"},"currency":{"default":"USD","type":"string","example":"USD"},"day":{"type":"string","format":"date","example":"2026-05-15"},"tags":{"type":"object","additionalProperties":true},"environment":{"type":"string","example":"prod"},"region":{"type":"string","example":"us-east-1"},"amount_cents":{"type":"integer","description":"Cost as integer cents. $12.34 → 1234.","example":4287},"cloud_provider":{"type":"string","example":"aws"},"resource_kind":{"type":"string","description":"compute | storage | network | database | observability | ai | …","example":"compute"},"team":{"type":"string","example":"payments"}}},"Deployment":{"type":"object","description":"Marks a service deploy. Drawn as vertical lines on metric charts.","required":["service","version"],"properties":{"version":{"type":"string","example":"v2.4.0"},"time":{"type":"string","format":"date-time","description":"Defaults to ingestion time if absent."},"service":{"type":"string","example":"checkout-api"},"metadata":{"type":"object","additionalProperties":true},"source":{"default":"manual","type":"string","enum":["detected","manual","ci"],"description":"`detected` is reserved for Funnel's own attribute-change detector."},"environment":{"type":"string","example":"prod"},"previous_version":{"type":"string","nullable":true,"example":"v2.3.7"},"commit_sha":{"type":"string","example":"9a8b7c6d"},"link_url":{"type":"string","format":"uri","example":"https://github.com/acme/checkout/actions/runs/123"}}},"Finding":{"type":"object","description":"A single security finding. With `external_id`, repeats dedupe on `(project_id, source, external_id)`.","required":["source","kind","title"],"properties":{"line":{"type":"integer"},"status":{"default":"open","type":"string","enum":["open","acknowledged","suppressed","resolved"]},"description":{"type":"string","nullable":true},"metadata":{"type":"object","additionalProperties":true},"title":{"type":"string","example":"CVE-2026-12345: openssl heap overflow"},"resource":{"type":"string","example":"arn:aws:s3:::prod-uploads"},"source":{"type":"string","enum":["siem","workload","secret_scanner","vuln_scanner"]},"kind":{"type":"string","description":"Free-form category, e.g. `iam.privilege_escalation`, `container.escape_attempt`, `leaked_secret`, `vuln.cve`.","example":"vuln.cve"},"severity":{"default":"medium","type":"string","enum":["info","low","medium","high","critical"]},"file_path":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"external_id":{"type":"string","description":"Stable identifier from the source scanner. Triggers dedupe-upsert when present.","example":"CVE-2026-12345"},"repository":{"type":"string","example":"smor/funnel"}}},"HostHeartbeat":{"type":"object","description":"Periodic agent heartbeat. Upserted by `(project_id, hostname)`.","required":["hostname"],"properties":{"status":{"default":"unknown","type":"string","enum":["healthy","degraded","down","unknown"]},"os":{"type":"string","nullable":true,"example":"linux"},"hostname":{"type":"string","example":"web-prod-03"},"kind":{"default":"host","type":"string","description":"host | container | lambda | gpu (free-form).","example":"host"},"arch":{"type":"string","nullable":true,"example":"arm64"},"tags":{"type":"object","additionalProperties":true},"region":{"type":"string","nullable":true,"example":"us-east-1"},"cloud_provider":{"type":"string","nullable":true,"example":"aws"},"cpu_pct":{"type":"number","description":"0–100"},"disk_pct":{"type":"number"},"gpu_memory_mb":{"type":"integer"},"gpu_pct":{"type":"number"},"instance_type":{"type":"string","nullable":true,"example":"c7g.xlarge"},"memory_pct":{"type":"number"}}},"IncidentCreate":{"type":"object","description":"Open an incident from an external system.","required":["title"],"properties":{"description":{"type":"string","nullable":true},"title":{"type":"string","example":"Checkout API: elevated 5xx rate"},"severity":{"default":"warning","type":"string","enum":["info","warning","critical"]},"tags":{"type":"array","items":{"type":"string"},"example":["checkout","pagerduty"]},"alert_rule_id":{"type":"integer","description":"Link to a Funnel alert rule, if any.","nullable":true},"alert_event_id":{"type":"integer","nullable":true},"triggered_at":{"type":"string","format":"date-time","description":"Defaults to ingestion time if absent."}}},"NativeLogRecord":{"type":"object","required":["severity","service_name","message"],"properties":{"attributes":{"$ref":"#/components/schemas/Attributes"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"},"severity":{"type":"string","enum":["debug","info","warn","error","fatal"]},"service_name":{"type":"string"},"trace_id":{"type":"string","nullable":true},"span_id":{"type":"string","nullable":true}}},"NativeLogsRequest":{"type":"object","required":["logs"],"properties":{"logs":{"type":"array","items":{"$ref":"#/components/schemas/NativeLogRecord"}}}},"NativeMetricPoint":{"type":"object","required":["name","value"],"properties":{"attributes":{"$ref":"#/components/schemas/Attributes"},"name":{"type":"string","example":"http.server.duration_ms"},"value":{"type":"number"},"time":{"type":"string","format":"date-time"},"kind":{"default":"gauge","type":"string","enum":["counter","gauge","histogram","summary"]},"bucket_le":{"type":"number","description":"Upper bound for histogram buckets."}}},"NativeMetricsRequest":{"type":"object","required":["metrics"],"properties":{"metrics":{"type":"array","items":{"$ref":"#/components/schemas/NativeMetricPoint"}}}},"NativeSpan":{"type":"object","required":["start_time","end_time","trace_id","span_id","service_name","operation_name"],"properties":{"attributes":{"$ref":"#/components/schemas/Attributes"},"status":{"default":"ok","type":"string","enum":["ok","error"]},"events":{"type":"array","items":{"type":"object"}},"start_time":{"type":"string","format":"date-time"},"kind":{"default":"internal","type":"string","enum":["server","client","producer","consumer","internal"]},"operation_name":{"type":"string"},"service_name":{"type":"string"},"end_time":{"type":"string","format":"date-time"},"status_message":{"type":"string","nullable":true},"duration_ms":{"type":"number"},"trace_id":{"type":"string","description":"Hex string, conventionally 32 chars."},"span_id":{"type":"string","description":"Hex string, conventionally 16 chars."},"parent_span_id":{"type":"string","nullable":true}}},"NativeTracesRequest":{"type":"object","required":["spans"],"properties":{"spans":{"type":"array","items":{"$ref":"#/components/schemas/NativeSpan"}}}},"OtlpKeyValue":{"type":"object","properties":{"value":{"type":"object","description":"Tagged union; exactly one of stringValue, intValue, doubleValue, boolValue, arrayValue.","properties":{"arrayValue":{"type":"object","properties":{"values":{"type":"array"}}},"boolValue":{"type":"boolean"},"doubleValue":{"type":"number"},"intValue":{"type":"integer"},"stringValue":{"type":"string"}}},"key":{"type":"string"}}},"OtlpLogsRequest":{"type":"object","description":"OTLP `ExportLogsServiceRequest`.","required":["resourceLogs"],"properties":{"resourceLogs":{"type":"array","items":{"type":"object"}}}},"OtlpMetricsRequest":{"type":"object","description":"OTLP `ExportMetricsServiceRequest` (subset). See https://opentelemetry.io/docs/specs/otlp/.","required":["resourceMetrics"],"properties":{"resourceMetrics":{"type":"array","items":{"$ref":"#/components/schemas/OtlpResourceMetrics"}}}},"OtlpPartialSuccessBody":{"type":"object","description":"OTLP spec PartialSuccess wrapper. Always present in the 200 response, even on full success (in which case `partialSuccess` is `{}`).","properties":{"partialSuccess":{"type":"object","properties":{"errorMessage":{"type":"string","description":"Human-readable description of why some records were dropped."},"rejectedDataPoints":{"type":"integer","description":"Metrics-only: data points dropped (e.g. validation failure)."},"rejectedLogRecords":{"type":"integer","description":"Logs-only: log records dropped."},"rejectedSpans":{"type":"integer","description":"Traces-only: spans dropped (e.g. invalid trace_id)."}}}}},"OtlpResource":{"type":"object","properties":{"attributes":{"type":"array","items":{"$ref":"#/components/schemas/OtlpKeyValue"}}}},"OtlpResourceMetrics":{"type":"object","properties":{"resource":{"$ref":"#/components/schemas/OtlpResource"},"scopeMetrics":{"type":"array","items":{"$ref":"#/components/schemas/OtlpScopeMetrics"}}}},"OtlpScopeMetrics":{"type":"object","properties":{"scope":{"type":"object","properties":{"name":{"type":"string"},"version":{"type":"string"}}},"metrics":{"type":"array","items":{"type":"object"}}}},"OtlpTracesRequest":{"type":"object","description":"OTLP `ExportTraceServiceRequest`.","required":["resourceSpans"],"properties":{"resourceSpans":{"type":"array","items":{"type":"object"}}}},"RumBeacon":{"type":"object","description":"One beacon of Real User Monitoring events from a browser. Up to 500 events per beacon, 5 MB total decompressed.","required":["session_id","user_agent","events"],"properties":{"events":{"type":"array","description":"Up to 500 events. Larger batches return HTTP 413.","items":{"$ref":"#/components/schemas/RumEvent"},"maxItems":500},"release":{"type":"string","description":"Release identifier — typically a git SHA or semver. Persisted on every event so `error_stack` frames can be symbolicated against the uploaded `.map` files for this release. Max 64 chars matching `[A-Za-z0-9._:/-]+`.","nullable":true,"example":"abc1234","maxLength":64},"session_id":{"type":"string","description":"Unique per browser session. Must match `[A-Za-z0-9_:.-]{1,128}`. Used for session-glued queries (e.g. linking LCP `vitals` to the most recent `pageview` URL).","pattern":"^[A-Za-z0-9_:.-]{1,128}$","maxLength":128},"user_agent":{"type":"string","description":"Clamped server-side to 4 KB. Parsed into structured `browser_name` / `browser_version` / `os_name` / `os_version` / `device_type` / `is_bot` columns at ingest.","maxLength":4096}}},"RumEvent":{"type":"object","description":"A single browser event. Vital numeric fields (`lcp_ms`, etc.) that aren't actually numeric are silently coerced to `null` so a misbehaving SDK can't poison downstream percentile aggregations. String fields are clamped to 4 KB.","required":["kind"],"properties":{"attributes":{"type":"object","description":"Free-form attribute bag. Max 64 keys per event, max 8 nesting levels, max 2 KB per value. Non-string keys are rejected with 400. Every string leaf is scanned by RUM-scoped SDS rules before persistence; matches are redacted in place.","additionalProperties":true,"maxProperties":64},"time":{"description":"Event timestamp. Defaults to ingestion time if absent.","oneOf":[{"type":"string","format":"date-time"},{"type":"integer","description":"Unix milliseconds."}]},"kind":{"type":"string","enum":["pageview","vitals","error","navigation","click","console","custom"],"description":"Event category. Unknown values are coerced to `custom` rather than rejected — forward-compat with new SDK versions is automatic."},"error_message":{"type":"string","nullable":true,"maxLength":4096},"url":{"type":"string","maxLength":4096},"cls":{"type":"number","description":"Cumulative Layout Shift.","nullable":true},"duration_ms":{"type":"number","nullable":true},"error_stack":{"type":"string","nullable":true,"maxLength":4096},"fcp_ms":{"type":"number","description":"First Contentful Paint.","nullable":true},"inp_ms":{"type":"number","description":"Interaction to Next Paint.","nullable":true},"lcp_ms":{"type":"number","description":"Largest Contentful Paint.","nullable":true},"ttfb_ms":{"type":"number","description":"Time to first byte.","nullable":true}}},"SessionEvent":{"type":"object","description":"A single replayable event. `data` is opaque to Funnel — your replay player reconstructs it. SDS scans every string leaf inside `data` against enabled RUM-scoped rules before persistence; matches are redacted in place.","required":["kind"],"properties":{"data":{"type":"object","description":"Opaque payload. Capped at 512 KB after JSON encoding, max 16 levels of nesting. May contain `trace_id` for per-event cross-pillar linking.","additionalProperties":true,"maxProperties":5000},"time":{"description":"Event timestamp. Defaults to ingestion time if absent.","oneOf":[{"type":"string","format":"date-time"},{"type":"integer","description":"Unix milliseconds."}]},"kind":{"type":"string","enum":["dom_content_loaded","load","full_snapshot","incremental_snapshot","meta","custom","plugin","error","navigation","console","input","mouse"],"description":"Event category. The first seven match rrweb's numeric `type` enum\n(0..6) and are decoded for the player. The remaining four are\nnon-rrweb but useful as timeline marks:\n* `error` — increments the recording's `error_count` and renders\n  a red mark on the player scrubber.\n* `navigation` — pushState / hashchange / popstate, rendered as\n  a blue mark.\n* `console` — console.error / console.warn, rendered as an\n  orange / yellow mark.\n* `custom` — bag for SDK custom events\n  (`FunnelReplay.addEvent()`, `linkTrace()`). The SDK uses\n  `data.type = \"xhr\"` with `duration_ms >= 1000` to surface a\n  purple \"slow network\" mark on the timeline.\n\nUnknown kinds are coerced to `custom` rather than rejected so\nforward-compat with new rrweb subtypes is automatic.\n"}}},"SessionReplayBatch":{"type":"object","description":"One batch of rrweb-style events for a single browser session. The first batch creates the recording row; subsequent batches atomically increment counters and refresh `ended_at` via a single `ON CONFLICT DO UPDATE`. Header fields (user_identifier, user_agent, start_url) are sticky — first non-null wins via COALESCE so a later batch can't overwrite them with nil.","required":["session_id","events"],"properties":{"events":{"type":"array","description":"Up to 500 events per batch. Larger batches return HTTP 413 — split client-side. Total decompressed payload capped at 5 MB.","items":{"$ref":"#/components/schemas/SessionEvent"},"maxItems":500},"started_at":{"type":"string","format":"date-time","description":"First batch only. Subsequent batches may omit this.","nullable":true},"session_id":{"type":"string","description":"Unique per browser session. Must match `[A-Za-z0-9_:.-]{1,128}`. Use a UUID, nanoid, or the rrweb session id. The Funnel Replay SDK generates one automatically and persists it in `sessionStorage` for the duration of the tab session.","pattern":"^[A-Za-z0-9_:.-]{1,128}$","example":"s_2pK3xA9bF7d","maxLength":128},"user_agent":{"type":"string","nullable":true,"maxLength":250},"trace_id":{"type":"string","description":"**Cross-pillar correlation.** When set, Funnel persists `(session_id, trace_id)` into `session_trace_links` and the Traces explorer surfaces a 'Session replays' panel on the linked trace. Must be 16 or 32 hex chars (W3C trace-context format). Malformed IDs are silently dropped.","pattern":"^[a-fA-F0-9]{16,32}$","nullable":true,"example":"abc123def4567890abc123def4567890"},"start_url":{"type":"string","description":"URL of the page where the recording began. Sticky after the first batch.","nullable":true,"maxLength":250},"user_identifier":{"type":"string","description":"Stable identifier for the human (email, hashed id). Optional. Clamped server-side to 250 bytes. Used as the key for `DELETE /v1/sessions/users/{user_identifier}` right-to-erasure requests.","nullable":true,"example":"u_482","maxLength":250}}}},"securitySchemes":{"ApiKeyAuth":{"scheme":"bearer","type":"http","description":"Every request must include `Authorization: Bearer st_…` where the token\nis a Funnel project API key (created from the dashboard's\n**API Keys** page).\n\nFor RUM beacons (which run in browsers and can't safely carry HTTP\nheaders when using `navigator.sendBeacon`), the key may instead be\npassed as the query parameter `?token=st_…`.\n\nFor OTLP/gRPC on port 4317, the key is passed as gRPC metadata\n`authorization: Bearer st_…`.\n\nSuccessful verifications are cached for 60 seconds (`sha256`-keyed\nin ETS) so bcrypt runs at most once per minute per key — critical\nfor ingest throughput. Revoked keys take effect within 60s, or\nimmediately if `Funnel.Accounts.auth_cache_invalidate/1` is called\non the revoke path.\n","bearerFormat":"API key (st_…)"}}},"paths":{"/api/graphiql":{"get":{"description":"Browser-based query explorer with autocomplete, syntax highlighting,\nand live introspection of the schema. Only mounted in `dev` and\n`test` environments — production deployments expose only\n`/api/graphql`.\n","responses":{"200":{"description":"GraphiQL HTML page.","content":{"text/html":{}}},"404":{"description":"GraphiQL is not mounted in production."}},"summary":"Interactive GraphiQL playground (dev/test only).","tags":["GraphQL"]}},"/api/graphql":{"post":{"description":"Project-scoped GraphQL endpoint for SDS findings, statistics, budget,\nand writer health. Same `Authorization: Bearer st_…` header as the\nREST API; rate-limited at 8 req/sec per project.\n\nAvailable operations:\n\n  * `findings(filter, first, after)` — cursor-paginated finding list\n  * `finding(id)` — single finding by UUID\n  * `topRules`, `bySourceKind`, `topServices` — aggregations\n  * `budget`, `writerStats`, `health` — diagnostics\n  * `subscription { findingCreated(severity?) }` — live stream over\n    the Phoenix Socket (`/socket`)\n\nSee the Sensitive Data Scanner and GraphQL sections of the rendered\ndocs for worked examples and the full schema.\n","responses":{"200":{"description":"Standard GraphQL response envelope. `errors` may accompany `data` on partial failures.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","additionalProperties":true},"errors":{"type":"array","items":{"type":"object","properties":{"message":{"type":"string"},"path":{"type":"array","items":{"type":"string"}},"locations":{"type":"array","items":{"type":"object"}}}}}}},"example":{"data":{"topRules":[{"count":42,"severity":"CRITICAL","ruleName":"Built-in: Credit Card Number","uniqueFingerprints":17}]}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"description":"Rate-limited (GraphQL bucket: 8 req/sec per project)."}},"summary":"Execute a GraphQL query, mutation, or operation.","tags":["GraphQL"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["query"],"properties":{"query":{"type":"string","description":"The GraphQL document — query, mutation, or subscription."},"variables":{"type":"object","description":"Map of variable name → value referenced from the query.","additionalProperties":true},"operationName":{"type":"string","description":"When the document has multiple named operations, which one to run."}}},"examples":{"FindingsFiltered":{"value":{"query":"{\n  findings(first: 50, filter: { severity: CRITICAL, service: \"checkout\" }) {\n    nodes { id time ruleName matchedSample }\n    pageInfo { hasNextPage endCursor }\n    totalCount\n  }\n}\n"},"summary":"Critical findings on the checkout service."},"TopRules":{"value":{"query":"query TopRules($hours: Int!) {\n  topRules(hours: $hours, limit: 5) {\n    ruleName severity count uniqueFingerprints\n  }\n}\n","variables":{"hours":24}},"summary":"Top firing rules in the last 24h."}}}}},"x-graphiql-url":"/api/graphiql"}},"/opentelemetry.proto.collector.logs.v1.LogsService/Export":{"post":{"description":"Submit logs over gRPC. Wire-equivalent to POST /v1/logs with Content-Type: application/x-protobuf. SDS PII redaction applies before storage.","responses":{"200":{"description":"gRPC OK; trailers carry `grpc-status: 0`.","content":{"application/grpc+proto":{"schema":{"type":"string","format":"binary"}}}}},"summary":"Export logs (OTLP/gRPC).","tags":["Logs"],"requestBody":{"required":true,"content":{"application/grpc+proto":{"schema":{"type":"string","format":"binary"}}}},"x-grpc-method":"Export","x-grpc-service":"opentelemetry.proto.collector.logs.v1.LogsService","x-host-override":"grpc://localhost:4317","x-protocol":"grpc"}},"/opentelemetry.proto.collector.metrics.v1.MetricsService/Export":{"post":{"description":"Submit metrics over gRPC. Wire-equivalent to POST /v1/metrics with Content-Type: application/x-protobuf. Available on port 4317 when FUNNEL_GRPC_ENABLED=1.","responses":{"200":{"description":"gRPC OK. Body is gRPC-framed ExportMetricsServiceResponse. HTTP/2 trailers carry `grpc-status: 0` and optional `grpc-message`.","content":{"application/grpc+proto":{"schema":{"type":"string","format":"binary"}}}}},"summary":"Export metrics (OTLP/gRPC).","tags":["Metrics"],"requestBody":{"required":true,"content":{"application/grpc+proto":{"schema":{"type":"string","format":"binary","description":"gRPC-framed ExportMetricsServiceRequest protobuf. Compression-flag byte + 4-byte big-endian length + message bytes."}}}},"x-grpc-method":"Export","x-grpc-service":"opentelemetry.proto.collector.metrics.v1.MetricsService","x-host-override":"grpc://localhost:4317","x-protocol":"grpc"}},"/opentelemetry.proto.collector.trace.v1.TraceService/Export":{"post":{"description":"Submit spans over gRPC. Wire-equivalent to POST /v1/traces with Content-Type: application/x-protobuf.","responses":{"200":{"description":"gRPC OK; trailers carry `grpc-status: 0`.","content":{"application/grpc+proto":{"schema":{"type":"string","format":"binary"}}}}},"summary":"Export spans (OTLP/gRPC).","tags":["Traces"],"requestBody":{"required":true,"content":{"application/grpc+proto":{"schema":{"type":"string","format":"binary"}}}},"x-grpc-method":"Export","x-grpc-service":"opentelemetry.proto.collector.trace.v1.TraceService","x-host-override":"grpc://localhost:4317","x-protocol":"grpc"}},"/v1/cost/records":{"post":{"description":"Send daily-grain cost line items (one row per day × cloud × resource\nkind × service). Funnel's cost-attributor cron joins these against\nthe Software Catalog on `service.name` to stamp `tier` and\n`owner_team` so cost dashboards can group by team without a manual\nmapping table.\n\nRequired scope: `ingest:costs`.\n\n`amount_cents` is always an **integer**: $123.45 → `12345`. Pick\na single `currency` per row (USD is the default).\n","responses":{"200":{"description":"Accepted.","content":{"application/json":{"schema":{"type":"object","properties":{"accepted":{"type":"integer"},"errors":{"type":"integer"}}},"example":{"accepted":12,"errors":0}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"}},"summary":"Bulk ingest daily cloud-cost rollups.","tags":["Platform"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["records"],"properties":{"records":{"type":"array","items":{"$ref":"#/components/schemas/CostRecord"}}}},"example":{"records":[{"amount_cents":4287,"cloud_provider":"aws","currency":"USD","day":"2026-05-15","environment":"prod","region":"us-east-1","resource_kind":"compute","service":"checkout-api","tags":{"cost-category":"ec2"},"team":"payments"},{"amount_cents":1832,"cloud_provider":"aws","day":"2026-05-15","environment":"prod","region":"us-east-1","resource_kind":"database","service":"checkout-api","team":"payments"}]}}}}}},"/v1/deployments":{"post":{"description":"POST from CI/CD when a service ships a new version. The marker is\nrendered as a vertical dashed line on every metric chart that\noverlaps its timestamp — so you can correlate latency / error\nspikes with deploys at a glance.\n\nRequired scope: `ingest:deployments`.\n\nFunnel *also* auto-detects deploys from `deployment.version`\nattribute changes on incoming spans/metrics, so this endpoint is\noptional. Manual posts get `source: \"ci\"` (or `\"manual\"`) instead\nof the auto-detection's `\"detected\"`.\n","responses":{"200":{"description":"Marker recorded.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"integer"},"version":{"type":"string"},"service":{"type":"string"},"accepted":{"type":"boolean"}}},"example":{"id":7,"version":"v2.4.0","service":"checkout-api","accepted":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}},"summary":"Record a deploy marker.","tags":["Platform"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Deployment"},"example":{"commit_sha":"9a8b7c6d5e4f3a2b1c0d","environment":"prod","link_url":"https://github.com/acme/checkout/actions/runs/123456","metadata":{"pipeline":"github-actions","run_id":"123456"},"previous_version":"v2.3.7","service":"checkout-api","source":"ci","time":"2026-05-15T17:42:01Z","version":"v2.4.0"}}}}}},"/v1/findings":{"post":{"description":"Unified endpoint for all four security pillars. Discriminate by\n`source`:\n\n| `source`           | typical sender                       |\n|--------------------|--------------------------------------|\n| `siem`             | cloud SIEM (CloudTrail, GuardDuty)   |\n| `workload`         | eBPF / runtime sensors               |\n| `secret_scanner`   | gitleaks, trufflehog, GitHub secrets |\n| `vuln_scanner`     | trivy, snyk, dependabot              |\n\nRequired scope: `ingest:findings`.\n\n**Deduplication.** Findings carrying an `external_id` upsert on\n`(project_id, source, external_id)`. On repeat, `last_seen_at`\nand `seen_count` bump; severity escalates if higher than the\nstored value. Findings without `external_id` always insert.\n","responses":{"200":{"description":"Accepted (with dedupe counts).","content":{"application/json":{"schema":{"type":"object","properties":{"accepted":{"type":"integer","description":"Newly inserted findings."},"errors":{"type":"integer"},"deduped":{"type":"integer","description":"Existing findings whose seen_count was bumped."}}},"example":{"accepted":3,"errors":0,"deduped":5}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"}},"summary":"Bulk ingest security findings (SIEM / SCA / secrets / vulns).","tags":["Platform"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["findings"],"properties":{"findings":{"type":"array","items":{"$ref":"#/components/schemas/Finding"}}}},"example":{"findings":[{"external_id":"CVE-2026-12345","kind":"vuln.cve","metadata":{"package":"openssl","version_affected":"<3.2.1"},"repository":"smor/funnel","severity":"high","source":"vuln_scanner","title":"CVE-2026-12345: openssl heap overflow"},{"external_id":"gitleaks/aws-key-abc123","file_path":"config/runtime.exs","kind":"leaked_secret","line":42,"repository":"smor/funnel","severity":"critical","source":"secret_scanner","title":"AWS access key in commit"}]}}}}}},"/v1/hosts/heartbeat":{"post":{"description":"Single endpoint for infrastructure auto-discovery. The host is\nupserted by `(project_id, hostname)`. Sending periodic heartbeats\n(every 30–60s) keeps `last_heartbeat_at` fresh; the dashboard\nflips status to `stale` after 2 missed intervals.\n\nRequired scope: `ingest:hosts`.\n\n`kind` is free-form: typical values are `host`, `container`,\n`lambda`, `gpu`. Cloud attribution (`cloud_provider`, `region`,\n`instance_type`) is optional but powers cost-attribution joins.\n","responses":{"200":{"description":"Heartbeat accepted.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"integer"},"status":{"type":"string","enum":["healthy","degraded","down","unknown"]},"accepted":{"type":"boolean"}}},"example":{"id":17,"status":"healthy","accepted":true}}}},"400":{"description":"Missing `hostname`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"}},"summary":"Agent heartbeat — host / container / serverless / GPU.","tags":["Platform"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HostHeartbeat"},"example":{"arch":"arm64","cloud_provider":"aws","cpu_pct":32.1,"disk_pct":41.2,"hostname":"web-prod-03","instance_type":"c7g.xlarge","kind":"host","memory_pct":64.8,"os":"linux","region":"us-east-1","status":"healthy","tags":{"env":"prod","role":"web"}}}}}}},"/v1/incidents":{"post":{"description":"PagerDuty, Statuspage, custom CI, or any external alerting system\ncan POST here to create an incident in Funnel's incident-management\nsurface. The incident is linked to an `alert_rule_id` /\n`alert_event_id` if provided (otherwise `source` defaults to\n`manual` and `auto_created` is `false`).\n\nRequired scope: `ingest:incidents`.\n\nNote: incidents created by Funnel's own alert evaluator\n(`auto_create_incident=true` rules) do not go through this\nendpoint — they bypass HTTP entirely.\n","responses":{"200":{"description":"Incident opened.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"integer"},"status":{"type":"string"},"accepted":{"type":"boolean"}}},"example":{"id":42,"status":"open","accepted":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"},"422":{"$ref":"#/components/responses/UnprocessableEntity"}},"summary":"Open an incident from an external system.","tags":["Platform"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncidentCreate"},"example":{"description":"Forwarded from PagerDuty incident PD-9182.","severity":"critical","tags":["checkout","pagerduty"],"title":"Checkout API: elevated 5xx rate","triggered_at":"2026-05-15T17:42:00Z"}}}}}},"/v1/logs":{"post":{"description":"Accepts OTLP/JSON, OTLP/Protobuf, or a native body. See\n`/v1/metrics` for full encoding/compression details.\n\nMessages are full-text indexed via Postgres `tsvector('english')`\nfor fast log search. Severity is normalized from OTLP severityNumber\n(1-24) to one of `debug | info | warn | error | fatal`.\n\nLogs containing PII matched by Sensitive Data Scanner rules are\nredacted in-place before storage — raw secrets never reach\nPostgres. See `/docs#sds` for rule configuration.\n","responses":{"200":{"$ref":"#/components/responses/OtlpPartialSuccess"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/BurstRateLimited"},"503":{"$ref":"#/components/responses/ServiceOverloaded"}},"summary":"Ingest log records (OTLP/HTTP).","tags":["Logs"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/OtlpLogsRequest"},{"$ref":"#/components/schemas/NativeLogsRequest"}]},"examples":{"native":{"value":{"logs":[{"attributes":{"env":"prod"},"message":"handled GET /users in 182ms","service_name":"api","severity":"info","time":"2026-05-11T17:00:00Z"},{"attributes":{"customer_id":"cus_abc"},"message":"charge failed: card_declined","service_name":"billing","severity":"error","time":"2026-05-11T17:00:01Z","trace_id":"a1f3c4d5b6e7890123456789abcdef00"}]},"summary":"Native logs"},"otlp":{"value":{"resourceLogs":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"api"}}]},"scopeLogs":[{"logRecords":[{"attributes":[{"key":"http.method","value":{"stringValue":"GET"}}],"body":{"stringValue":"handled GET /users in 182ms"},"severityNumber":9,"severityText":"INFO","timeUnixNano":"1715451600000000000"}]}]}]},"summary":"OTLP/JSON logs"}}},"application/x-protobuf":{"schema":{"type":"string","format":"binary","description":"ExportLogsServiceRequest serialized as protobuf."}}}}}},"/v1/metrics":{"post":{"description":"Accepts:\n\n* **OTLP/JSON** body (`Content-Type: application/json`) with\n  `resourceMetrics`, the canonical OTLP shape.\n* **OTLP/Protobuf** body (`Content-Type: application/x-protobuf`)\n  — the same `ExportMetricsServiceRequest` message encoded with\n  standard protobuf wire format.\n* **Native** simplified body (`{ \"metrics\": [...] }`) for the\n  demo client and quick ad-hoc tests.\n\n`Content-Encoding: gzip` is transparently decompressed for all\nthree. Decompressed bodies are capped at 50 MB to prevent\ngzip-bomb DoS.\n\nReturns spec-compliant OTLP `partialSuccess` envelopes — including\n`rejectedDataPoints` and an error message when some records are\ndropped (e.g. validation failures, body too large, decoder errors).\n\nThe same `ExportMetricsServiceRequest` is also accepted over real\ngRPC on port 4317 — see `/docs#otlp-grpc`.\n","responses":{"200":{"$ref":"#/components/responses/OtlpPartialSuccess"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/BurstRateLimited"},"503":{"$ref":"#/components/responses/ServiceOverloaded"}},"summary":"Ingest metric data points (OTLP/HTTP).","tags":["Metrics"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/OtlpMetricsRequest"},{"$ref":"#/components/schemas/NativeMetricsRequest"}]},"examples":{"native":{"value":{"metrics":[{"attributes":{"method":"GET","route":"/users","service":"api"},"kind":"gauge","name":"http.server.duration_ms","time":"2026-05-11T17:00:00Z","value":182.3},{"attributes":{"service":"api"},"kind":"counter","name":"http.server.requests","time":"2026-05-11T17:00:00Z","value":1.0}]},"summary":"Simple native shape"},"otlp":{"value":{"resourceMetrics":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"api"}}]},"scopeMetrics":[{"metrics":[{"histogram":{"dataPoints":[{"attributes":[{"key":"http.route","value":{"stringValue":"/users"}}],"bucketCounts":[0,2,5,4,1,0,0],"count":12,"explicitBounds":[10,50,100,250,500,1000],"sum":1840.5,"timeUnixNano":"1715451600000000000"}]},"name":"http.server.duration","unit":"ms"}],"scope":{"name":"my-app","version":"1.0.0"}}]}]},"summary":"OTLP/JSON ExportMetricsServiceRequest"}}},"application/x-protobuf":{"schema":{"type":"string","format":"binary","description":"ExportMetricsServiceRequest serialized as protobuf."}}}}}},"/v1/rum/events":{"post":{"description":"Receives a batched beacon from the Funnel browser SDK. Designed\nfor `navigator.sendBeacon`, so:\n\n* The API key may be passed either as `Authorization: Bearer …`\n  **or** as the `?api_key=` query parameter (preferred for\n  beacons, since `sendBeacon` cannot set headers). The legacy\n  `?token=` parameter is still accepted as a synonym.\n* CORS is open (`Access-Control-Allow-Origin: *`).\n* The response is a JSON body with the accepted count.\n\nRequired scope: `ingest:rum`.\n\n### Production-grade guarantees\n\n* **Origin allowlist.** When an API key has a non-empty\n  `allowed_origins` array, the request's `Origin` header MUST\n  match one of the entries (exact host or `*.example.com`\n  wildcard). A scraped public key cannot be used from an\n  attacker-controlled origin. See `Funnel.Origin.matches?/2`.\n* **Auth cache.** Bcrypt runs at most once per key per minute\n  regardless of beacon rate. The 60 s ETS cache is shared with\n  the OTLP and Session-Replay paths.\n* **Bounded inputs.** Max 500 events per request, 5 MB total\n  payload, 4 KB per string field (URL, user_agent,\n  error_message, error_stack), 64 attribute keys per event,\n  8 nesting levels for attribute values. Excess returns 413\n  or 400 with a specific message.\n* **No atom-table DoS.** Attribute keys are never run through\n  `String.to_atom/1`. Non-string keys are rejected with 400.\n* **Quota-gated.** Burst (token bucket) and daily (bytes /\n  events) caps are checked before the batch enters the\n  pipeline and recorded on success.\n* **SDS-redacted.** The async pipeline shard runs\n  `Funnel.Sds.redact_rum/2` against `url`, `error_message`,\n  and attribute string leaves before insert. Raw secrets\n  never reach Postgres.\n* **Backpressure.** Returns 503 with `Retry-After` when the\n  in-memory pipeline buffer exceeds its high-water mark.\n* **Observable.** Emits `[:funnel, :rum, :ingest, :stop]`\n  telemetry with `{count, bytes, errors, duration_us}` and a\n  disposition status.\n* **Right-to-erasure.** `Funnel.Rum.purge_user/3` deletes\n  every event for a given `session_id` set with an audit-log\n  row, used to fulfil GDPR Article 17 / DSAR requests.\n\nTime fields accept ISO 8601 strings, Unix milliseconds, or\n`null` (defaults to ingestion time). Vital values that aren't\nnumeric are silently coerced to `null` so a misbehaving SDK\ncan't corrupt downstream percentile aggregations.\n","parameters":[{"in":"query","name":"api_key","description":"API key — alternative to `Authorization` for `sendBeacon`.","required":false,"schema":{"type":"string"}},{"in":"header","name":"Origin","description":"Browser-supplied origin. Required when the API key has a non-empty `allowed_origins` list; matched via exact host or `*.example.com` wildcard.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Accepted.","content":{"application/json":{"schema":{"type":"object","properties":{"accepted":{"type":"integer","description":"Number of events enqueued."}}},"example":{"accepted":3}}}},"400":{"description":"Validation failure. Body returns `{ \"error\": \"<reason>\" }`. Reasons include: `expected a JSON object`, `session_id must be 1..128 chars of [A-Za-z0-9_:.-]`, `events must be an array`, `every event must be a JSON object`, `attributes contain a non-string key`, `attributes nested beyond the allowed depth`, `an event has more than 64 attribute keys`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"an event has more than 64 attribute keys"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Origin not in the key's `allowed_origins` list (when set), or required scope missing.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"origin not allowed"}}}},"413":{"description":"Size cap exceeded. Reasons: `batch contains N events; max 500 per request` / `payload is N bytes; max 5MB per request`."},"429":{"description":"Rate limited. `burst rate limit exceeded` returns a `Retry-After` header; `daily X quota exceeded (used/limit)` indicates the daily plan cap was reached.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}}},"503":{"description":"Ingest pipeline buffer over its high-water mark. `Retry-After` header indicates when to try again.","headers":{"Retry-After":{"schema":{"type":"integer"}}}}},"summary":"Browser Real User Monitoring beacon (production-grade).","tags":["RUM"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RumBeacon"},"example":{"events":[{"fcp_ms":410,"kind":"pageview","time":1715451600000,"ttfb_ms":88,"url":"https://example.com/pricing"},{"cls":0.04,"inp_ms":95,"kind":"vitals","lcp_ms":1240,"time":1715451600500},{"error_message":"TypeError: cannot read 'x' of undefined","error_stack":"at PaymentForm…","kind":"error","time":1715451601000}],"session_id":"s_abc123xyz","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/..."}}}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}]}},"/v1/rum/sessions":{"post":{"description":"Backward-compatible alias for **`POST /v1/sessions`**. Same\npipeline, same payload shape, same response contract. Prefer\nthe canonical `/v1/sessions` for new integrations.\n","responses":{"200":{"description":"Accepted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptResponse"},"example":{"accepted":4,"redacted":0}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"},"413":{"description":"See `/v1/sessions` for size-cap details."},"429":{"description":"See `/v1/sessions` for burst/quota details."},"503":{"description":"Persistence unavailable. Safe to retry."}},"summary":"Ingest a batch of session-replay events (legacy alias).","tags":["Session Replay"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionReplayBatch"},"example":{"events":[{"data":{"height":900,"href":"https://example.com/checkout","width":1440},"kind":"meta","time":1715793721000},{"data":{"initialOffset":{"left":0,"top":0},"node":{"childNodes":["…rrweb tree…"],"type":0}},"kind":"full_snapshot","time":1715793721100},{"data":{"adds":["…"],"source":2,"type":"mutation"},"kind":"incremental_snapshot","time":1715793722400},{"data":{"id":87,"source":5,"text":"•••• •••• •••• 4242"},"kind":"input","time":1715793723900},{"data":{"message":"TypeError: Cannot read 'amount' of undefined","stack":"at PaymentForm.submit (/static/js/app.js:42:11)","trace_id":"abc123def4567890abc123def4567890"},"kind":"error","time":1715793725100}],"session_id":"s_2pK3xA9bF7d","start_url":"https://example.com/checkout","started_at":"2026-05-15T17:42:01.000Z","trace_id":"abc123def4567890abc123def4567890","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/...","user_identifier":"u_482"}}}}}},"/v1/sessions":{"post":{"description":"Canonical endpoint for the Session Replay SDK. Accepts batched\nrrweb-style events with the same payload shape and response\ncontract as `/v1/rum/sessions` (which is now kept as an alias\nfor backward compatibility).\n\n### Why a dedicated browser-ingestion pipeline?\n\nThis route runs through the `:browser_ingestion` pipeline:\n\n* **CORS open** (`Access-Control-Allow-Origin: *`) so the SDK\n  can post from any origin.\n* **`?api_key=` query-param auth** is accepted as a synonym for\n  `Authorization: Bearer` — required so `navigator.sendBeacon`\n  (which can't set headers) works on `pagehide`.\n* **Cached bcrypt** via the auth cache — high-volume browser\n  traffic doesn't pay the bcrypt cost on every beacon.\n\nRequired scope: `ingest:sessions`.\n\n### Production-grade guarantees\n\n* **Bounded inputs.** Max 500 events per request, 512 KB per\n  event `data`, 5 MB total decompressed payload, 16 levels of\n  JSON nesting, 250 bytes for header strings (UA, URL, user\n  identifier). Anything over the cap returns 413 with a\n  specific error message.\n* **No atom-table DoS.** User-supplied JSON keys are never run\n  through `String.to_atom/1`. Every accepted field is read by\n  a known string key.\n* **Atomic counter upserts.** `event_count` / `error_count` on\n  `session_recordings` are incremented via a single SQL\n  `INSERT ... ON CONFLICT DO UPDATE`. Concurrent batches for\n  the same session both succeed without lost updates.\n* **Quota-gated.** Both burst (token-bucket) and daily\n  (bytes/events) caps are checked before persistence and\n  recorded after success. Rejections map to 429 with\n  `Retry-After`.\n* **SDS-aware.** Every string leaf in event `data` is scanned\n  by enabled RUM-scoped Sensitive Data Scanner rules. Matches\n  are redacted in place before the row reaches Postgres and a\n  `sds_findings` row is recorded with\n  `source_kind = \"sessions\"`.\n* **Hot-path crash-safe.** DB failures return\n  `{ \"error\": \"storage temporarily unavailable\" }` with HTTP\n  503 rather than crashing the controller process.\n* **Cross-pillar trace linking.** A top-level `trace_id` or a\n  per-event `data.trace_id` field is persisted to\n  `session_trace_links`; the Traces explorer shows a\n  \"Session replays\" panel for any trace touched by a session.\n* **Observable.** Emits `[:funnel, :sessions, :ingest, :stop]`\n  telemetry with `{count, errors, bytes, duration_us}` and a\n  disposition status (`:ok` / `:rate_limited` / `:quota_exceeded`\n  / `:invalid_session_id` / `:persist_failed` / ...).\n\nTime fields accept ISO 8601 strings, Unix milliseconds, or\n`null` (defaults to ingestion time).\n","responses":{"200":{"description":"Accepted.","content":{"application/json":{"schema":{"type":"object","properties":{"accepted":{"type":"integer","description":"Number of events persisted."},"redacted":{"type":"integer","description":"How many events had a string leaf redacted by SDS."}}},"example":{"accepted":4,"redacted":1}}}},"400":{"description":"Validation failure. Body returns `{ \"error\": \"<reason>\" }`. Reasons: `session_id required` / `session_id must be 1..128 chars of [A-Za-z0-9_:.-]` / `events must be an array` / `event data nested beyond the allowed depth` / `event data contains invalid key type` / `events must be objects`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"session_id must be 1..128 chars of [A-Za-z0-9_:.-]"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"},"413":{"description":"Payload exceeded a size cap. Reasons: `batch contains N events; max 500 per request` / `payload is N bytes; max 5MB per request` / `an event's data exceeds the 512KB per-event cap`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"batch contains 1024 events; max 500 per request"}}}},"429":{"description":"Rate limited. `burst rate limit exceeded` returns a `Retry-After` header; `daily X quota exceeded (used/limit)` indicates the daily plan cap was reached.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"daily events quota exceeded (100000/100000)"}}},"headers":{"Retry-After":{"description":"Seconds to wait before retrying (burst limit only).","schema":{"type":"integer"}}}},"503":{"description":"Persistence layer temporarily unavailable. Safe to retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}},"example":{"error":"storage temporarily unavailable"}}}}},"summary":"Ingest a batch of session-replay events (canonical).","tags":["Session Replay"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionReplayBatch"},"example":{"events":[{"data":{"height":900,"href":"https://example.com/checkout","width":1440},"kind":"meta","time":1715793721000},{"data":{"initialOffset":{"left":0,"top":0},"node":{"childNodes":["…rrweb tree…"],"type":0}},"kind":"full_snapshot","time":1715793721100},{"data":{"adds":["…"],"source":2,"type":"mutation"},"kind":"incremental_snapshot","time":1715793722400},{"data":{"id":87,"source":5,"text":"•••• •••• •••• 4242"},"kind":"input","time":1715793723900},{"data":{"message":"TypeError: Cannot read 'amount' of undefined","stack":"at PaymentForm.submit (/static/js/app.js:42:11)","trace_id":"abc123def4567890abc123def4567890"},"kind":"error","time":1715793725100}],"session_id":"s_2pK3xA9bF7d","start_url":"https://example.com/checkout","started_at":"2026-05-15T17:42:01.000Z","trace_id":"abc123def4567890abc123def4567890","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/...","user_identifier":"u_482"}}}}}},"/v1/sourcemaps":{"post":{"description":"Customer build pipeline uploads each `.map` file once per\nrelease. Funnel rewrites every matching `error_stack` frame\non the read path — minified `app.min.js:1:48211` becomes\n`src/PaymentForm.tsx:189:24`.\n\nIdempotent: re-uploading a `(release, file)` replaces the\nprevious content. Maximum content size: 50 MB.\n\nRequired scope: `ingest:rum`.\n\nSee the bundled CLI: `mix funnel.upload_sourcemaps --release\n$(git rev-parse HEAD) --key st_… dist/`.\n","responses":{"200":{"description":"Stored.","content":{"application/json":{"schema":{"type":"object","properties":{"stored":{"type":"object","properties":{"file":{"type":"string"},"release":{"type":"string"},"bytes_gz":{"type":"integer"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"},"413":{"description":"Source-map content > 50 MB."}},"summary":"Upload a JavaScript source map for server-side symbolication.","tags":["RUM"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["release","file","content"],"properties":{"file":{"type":"string","description":"Minified filename to match against stack frames, e.g. `app.min.js`.","example":"app.min.js"},"release":{"type":"string","description":"Release identifier — git SHA, semver, build number. Must match `[A-Za-z0-9._:/-]{1,64}`.","example":"abc1234"},"content":{"type":"string","description":"Raw JSON content of the `.map` file. Stored gzipped server-side."}}}}}}}},"/v1/traces":{"post":{"description":"Accepts OTLP/JSON, OTLP/Protobuf, or a simplified native body.\nSee `/v1/metrics` for full encoding/compression details — the\nthree signal endpoints share the same OTLP plumbing.\n\nSpans are validated: `trace_id` must be 32 hex chars (16 bytes\nencoded), `span_id` 16 hex chars. All-zero IDs are rejected as\ninvalid per W3C trace-context. Failures appear in the response\nas `rejectedSpans`.\n\nPersisted into a monthly-partitioned `spans` table, indexed by\n`(project_id, trace_id)` for trace reconstruction.\n","responses":{"200":{"$ref":"#/components/responses/OtlpPartialSuccess"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/MissingScope"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/BurstRateLimited"},"503":{"$ref":"#/components/responses/ServiceOverloaded"}},"summary":"Ingest spans (OTLP/HTTP).","tags":["Traces"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/OtlpTracesRequest"},{"$ref":"#/components/schemas/NativeTracesRequest"}]},"examples":{"native":{"value":{"spans":[{"attributes":{"http.method":"GET","http.route":"/users"},"duration_ms":250.0,"end_time":"2026-05-11T17:00:00.250Z","kind":"server","operation_name":"GET /users","parent_span_id":null,"service_name":"api","span_id":"0123456789abcdef","start_time":"2026-05-11T17:00:00.000Z","status":"ok","trace_id":"a1f3c4d5b6e7890123456789abcdef00"},{"duration_ms":160.0,"end_time":"2026-05-11T17:00:00.180Z","kind":"client","operation_name":"SELECT users","parent_span_id":"0123456789abcdef","service_name":"db","span_id":"fedcba9876543210","start_time":"2026-05-11T17:00:00.020Z","status":"ok","trace_id":"a1f3c4d5b6e7890123456789abcdef00"}]},"summary":"Two-span trace (parent + DB call)"},"otlp":{"value":{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"api"}}]},"scopeSpans":[{"spans":[{"attributes":[{"key":"http.method","value":{"stringValue":"GET"}}],"endTimeUnixNano":"1715451600250000000","kind":2,"name":"GET /users","spanId":"0123456789abcdef","startTimeUnixNano":"1715451600000000000","status":{"code":1},"traceId":"a1f3c4d5b6e7890123456789abcdef00"}]}]}]},"summary":"OTLP/JSON ExportTraceServiceRequest"}}},"application/x-protobuf":{"schema":{"type":"string","format":"binary","description":"ExportTraceServiceRequest serialized as protobuf."}}}}}},"/w/{org_slug}/{project_slug}/{worker_slug}":{"get":{"description":"Public entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker (GET).","tags":["Edge Workers"],"requestBody":null,"security":[]},"put":{"description":"Public entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker (PUT).","tags":["Edge Workers"],"requestBody":{"description":"Whatever the worker expects. Up to 1MB.","required":false,"content":{"application/json":{"schema":{"type":"object"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"application/x-www-form-urlencoded":{"schema":{"type":"object"}},"text/plain":{"schema":{"type":"string"}}}},"security":[]},"delete":{"description":"Public entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker (DELETE).","tags":["Edge Workers"],"requestBody":{"description":"Whatever the worker expects. Up to 1MB.","required":false,"content":{"application/json":{"schema":{"type":"object"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"application/x-www-form-urlencoded":{"schema":{"type":"object"}},"text/plain":{"schema":{"type":"string"}}}},"security":[]},"options":{"description":"When a browser issues a CORS preflight (`OPTIONS` + `Origin` header)\nand the requested origin appears in the worker's `cors_origins`\nallow-list, Funnel responds with **204** and the appropriate\n`Access-Control-Allow-Origin / Methods / Headers / Max-Age` echo\n(plus `Vary: Origin`). Non-matching origin → **403** with no CORS\nheaders (browser will block the actual request).\n\nPreflight never reaches the user's code.\n","parameters":[{"in":"header","name":"Origin","required":true,"schema":{"type":"string"}},{"in":"header","name":"Access-Control-Request-Method","required":false,"schema":{"type":"string"}},{"in":"header","name":"Access-Control-Request-Headers","required":false,"schema":{"type":"string"}}],"responses":{"204":{"description":"Origin allow-listed. CORS headers echoed back.","headers":{"Access-Control-Allow-Headers":{"description":"Echoes `Access-Control-Request-Headers` from the preflight.","schema":{"type":"string"}},"Access-Control-Allow-Methods":{"schema":{"type":"string"},"example":"GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS"},"Access-Control-Allow-Origin":{"schema":{"type":"string"}},"Access-Control-Max-Age":{"schema":{"type":"string"},"example":"3600"},"Vary":{"schema":{"type":"string"},"example":"Origin"}}},"403":{"description":"Origin not in `cors_origins` allow-list.","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"Worker not found.","content":{"text/plain":{"schema":{"type":"string"}}}}},"summary":"CORS preflight.","tags":["Edge Workers"],"security":[]},"parameters":[{"in":"path","name":"org_slug","required":true,"schema":{"type":"string"}},{"in":"path","name":"project_slug","required":true,"schema":{"type":"string"}},{"in":"path","name":"worker_slug","required":true,"schema":{"type":"string"}}],"post":{"description":"Public entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker (POST).","tags":["Edge Workers"],"requestBody":{"description":"Whatever the worker expects. Up to 1MB.","required":false,"content":{"application/json":{"schema":{"type":"object"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"application/x-www-form-urlencoded":{"schema":{"type":"object"}},"text/plain":{"schema":{"type":"string"}}}},"security":[]},"patch":{"description":"Public entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker (PATCH).","tags":["Edge Workers"],"requestBody":{"description":"Whatever the worker expects. Up to 1MB.","required":false,"content":{"application/json":{"schema":{"type":"object"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"application/x-www-form-urlencoded":{"schema":{"type":"object"}},"text/plain":{"schema":{"type":"string"}}}},"security":[]}},"/w/{org_slug}/{project_slug}/{worker_slug}/_staging":{"get":{"description":"Routes the request to the worker's **staging** version\n(`staging_version_id`) instead of prod. The `_staging` URL segment is\nconsumed by the dispatcher — the worker sees the remaining path as\n`request.path`. Useful for testing a new version end-to-end before\npromoting it.\n\nReturns **404** with body `\"no staging version deployed\"` when the\nworker has no staging slot set.\n\nPublic entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker's staging version (GET).","tags":["Edge Workers"],"requestBody":null,"security":[]},"put":{"description":"Routes the request to the worker's **staging** version\n(`staging_version_id`) instead of prod. The `_staging` URL segment is\nconsumed by the dispatcher — the worker sees the remaining path as\n`request.path`. Useful for testing a new version end-to-end before\npromoting it.\n\nReturns **404** with body `\"no staging version deployed\"` when the\nworker has no staging slot set.\n\nPublic entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker's staging version (PUT).","tags":["Edge Workers"],"requestBody":{"description":"Whatever the worker expects. Up to 1MB.","required":false,"content":{"application/json":{"schema":{"type":"object"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"application/x-www-form-urlencoded":{"schema":{"type":"object"}},"text/plain":{"schema":{"type":"string"}}}},"security":[]},"delete":{"description":"Routes the request to the worker's **staging** version\n(`staging_version_id`) instead of prod. The `_staging` URL segment is\nconsumed by the dispatcher — the worker sees the remaining path as\n`request.path`. Useful for testing a new version end-to-end before\npromoting it.\n\nReturns **404** with body `\"no staging version deployed\"` when the\nworker has no staging slot set.\n\nPublic entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker's staging version (DELETE).","tags":["Edge Workers"],"requestBody":{"description":"Whatever the worker expects. Up to 1MB.","required":false,"content":{"application/json":{"schema":{"type":"object"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"application/x-www-form-urlencoded":{"schema":{"type":"object"}},"text/plain":{"schema":{"type":"string"}}}},"security":[]},"parameters":[{"in":"path","name":"org_slug","required":true,"schema":{"type":"string"}},{"in":"path","name":"project_slug","required":true,"schema":{"type":"string"}},{"in":"path","name":"worker_slug","required":true,"schema":{"type":"string"}}],"post":{"description":"Routes the request to the worker's **staging** version\n(`staging_version_id`) instead of prod. The `_staging` URL segment is\nconsumed by the dispatcher — the worker sees the remaining path as\n`request.path`. Useful for testing a new version end-to-end before\npromoting it.\n\nReturns **404** with body `\"no staging version deployed\"` when the\nworker has no staging slot set.\n\nPublic entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker's staging version (POST).","tags":["Edge Workers"],"requestBody":{"description":"Whatever the worker expects. Up to 1MB.","required":false,"content":{"application/json":{"schema":{"type":"object"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"application/x-www-form-urlencoded":{"schema":{"type":"object"}},"text/plain":{"schema":{"type":"string"}}}},"security":[]},"patch":{"description":"Routes the request to the worker's **staging** version\n(`staging_version_id`) instead of prod. The `_staging` URL segment is\nconsumed by the dispatcher — the worker sees the remaining path as\n`request.path`. Useful for testing a new version end-to-end before\npromoting it.\n\nReturns **404** with body `\"no staging version deployed\"` when the\nworker has no staging slot set.\n\nPublic entrypoint for the deployed worker. The response is **whatever\nthe worker returns**; status / headers / body are reflected verbatim\n(minus hop-by-hop headers).\n\n## Auth\n\nNo API key by default. If the worker has `auth_required = true`,\ncallers must send `Authorization: Bearer <token>` (token shown once\non rotation; only its SHA-256 hash is stored).\n\n## Headers passed to the worker\n\nAll request headers are passed through to `request.headers` **except**\n`cookie` and `authorization` (stripped before the sandbox sees them).\n`traceparent` passes through and is also extracted into the invocation\nrow's `attributes` for upstream-trace correlation.\n\n## Body\n\nUp to 1MB. Larger bodies are rejected with `413` before reaching user\ncode. JSON and form bodies arrive pre-parsed; raw content-types come\nthrough as a string.\n\n## Outbound (`funnel.fetch`)\n\nThe worker can make outbound HTTP via `funnel.fetch(url, opts)` if\n`outbound_allow_list` is configured. Egress is allow-listed by host,\nprivate/loopback/link-local destinations are blocked at the proxy,\nand the response body is capped at 1MB.\n","responses":{"200":{"description":"Whatever the worker returned. Status code may be any value the worker sets.","headers":{"Access-Control-Allow-Origin":{"description":"Echoed when the request's `Origin` matches the worker's `cors_origins` allow-list.","schema":{"type":"string"}},"Vary":{"description":"Set to `Origin` whenever CORS headers are emitted.","schema":{"type":"string"}}}},"401":{"description":"`auth_required` is set and the Bearer token is missing or wrong.","headers":{"WWW-Authenticate":{"schema":{"type":"string"},"example":"Bearer realm=\"funnel-worker\""}}},"404":{"description":"Project, worker, or active version not found."},"410":{"description":"Worker is disabled via the kill switch."},"413":{"description":"Request body exceeds the 1MB cap."},"429":{"description":"Either `daily_invocation_cap` reached for this worker, or per-project sandbox concurrency cap reached.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"500":{"description":"Runtime error in user code (exception, syntax error)."},"503":{"description":"Pool exhausted across all projects, or Node runtime not installed.","headers":{"Retry-After":{"schema":{"type":"string"},"example":"1"}}},"504":{"description":"Wall-clock timeout (`timeout_ms` exceeded)."}},"summary":"Invoke the worker's staging version (PATCH).","tags":["Edge Workers"],"requestBody":{"description":"Whatever the worker expects. Up to 1MB.","required":false,"content":{"application/json":{"schema":{"type":"object"}},"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"application/x-www-form-urlencoded":{"schema":{"type":"object"}},"text/plain":{"schema":{"type":"string"}}}},"security":[]}}},"servers":[{"description":"Local development (HTTP/JSON & HTTP/Protobuf)","url":"http://localhost:4000"},{"description":"Local development — OTLP/gRPC (opt-in: set FUNNEL_GRPC_ENABLED=1 before boot)","url":"grpc://localhost:4317","x-protocol":"grpc"},{"description":"Self-hosted production (HTTP/JSON & HTTP/Protobuf)","variables":{"host":{"default":"funnel.yourcompany.com"}},"url":"https://{host}"},{"description":"Self-hosted production — OTLP/gRPC","variables":{"host":{"default":"funnel.yourcompany.com"}},"url":"grpc://{host}:4317","x-protocol":"grpc"}],"tags":[{"name":"Metrics","description":"Numeric time-series ingestion. Accepts OTLP/JSON, OTLP/Protobuf (HTTP), and OTLP/gRPC on :4317."},{"name":"Traces","description":"Distributed tracing — spans and trace context. Accepts OTLP/JSON, OTLP/Protobuf (HTTP), and OTLP/gRPC on :4317."},{"name":"Logs","description":"Structured log records. Accepts OTLP/JSON, OTLP/Protobuf (HTTP), and OTLP/gRPC on :4317. SDS rules redact PII in-line."},{"name":"RUM","description":"Real User Monitoring beacons from the browser."},{"name":"Session Replay","description":"Batched session-replay events (rrweb-style). One recording header per session, many event rows."},{"name":"Platform","description":"Auto-discovery surfaces — infrastructure heartbeats, incidents, cost rollups, security findings, deploy markers."},{"name":"Edge Workers","description":"Public dispatch endpoint for user-deployed JS/safe-template workers. No API key required (these are end-user-published URLs). Per-worker `auth_required` + CORS + outbound allow-list, two-slot prod/staging versioning."}],"openapi":"3.1.0","security":[{"ApiKeyAuth":[]}],"x-buf-registry":{"module":"buf.build/open-telemetry/opentelemetry-proto","reference":"v1.3.2","note":"Funnel's gRPC server speaks the canonical opentelemetry-proto messages. Schema versions are tracked at https://buf.build/open-telemetry/opentelemetry-proto."},"x-grpc-services":[{"name":"opentelemetry.proto.collector.metrics.v1.MetricsService","methods":[{"name":"Export","request":"ExportMetricsServiceRequest","description":"Submit a batch of resource metrics. Same payload shape as POST /v1/metrics with Content-Type: application/x-protobuf.","response":"ExportMetricsServiceResponse","status-mapping":{"INTERNAL":"500 — Decoder failure or unhandled server error.","OK":"200 — Accepted, pipeline buffered the batch.","PERMISSION_DENIED":"403 — API key lacks the required `ingest:{metrics,traces,logs}` scope.","RESOURCE_EXHAUSTED":"429 — Burst rate limit OR daily quota exceeded for this project.","UNAUTHENTICATED":"401 — Missing or invalid `authorization: Bearer st_…` metadata.","UNAVAILABLE":"503 — Ingestion pipeline is shedding load. Retry with backoff."}}],"endpoint":"grpc://localhost:4317","package":"opentelemetry.proto.collector.metrics.v1","proto-url":"https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/metrics/v1/metrics_service.proto"},{"name":"opentelemetry.proto.collector.trace.v1.TraceService","methods":[{"name":"Export","request":"ExportTraceServiceRequest","description":"Submit a batch of resource spans. Invalid trace_id / span_id are returned in PartialSuccess.rejectedSpans.","response":"ExportTraceServiceResponse","status-mapping":{"INTERNAL":"500 — Decoder failure or unhandled server error.","OK":"200 — Accepted, pipeline buffered the batch.","PERMISSION_DENIED":"403 — API key lacks the required `ingest:{metrics,traces,logs}` scope.","RESOURCE_EXHAUSTED":"429 — Burst rate limit OR daily quota exceeded for this project.","UNAUTHENTICATED":"401 — Missing or invalid `authorization: Bearer st_…` metadata.","UNAVAILABLE":"503 — Ingestion pipeline is shedding load. Retry with backoff."}}],"endpoint":"grpc://localhost:4317","package":"opentelemetry.proto.collector.trace.v1","proto-url":"https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/trace/v1/trace_service.proto"},{"name":"opentelemetry.proto.collector.logs.v1.LogsService","methods":[{"name":"Export","request":"ExportLogsServiceRequest","description":"Submit a batch of resource logs. Sensitive Data Scanner rules redact PII before storage; raw secrets never reach Postgres.","response":"ExportLogsServiceResponse","status-mapping":{"INTERNAL":"500 — Decoder failure or unhandled server error.","OK":"200 — Accepted, pipeline buffered the batch.","PERMISSION_DENIED":"403 — API key lacks the required `ingest:{metrics,traces,logs}` scope.","RESOURCE_EXHAUSTED":"429 — Burst rate limit OR daily quota exceeded for this project.","UNAUTHENTICATED":"401 — Missing or invalid `authorization: Bearer st_…` metadata.","UNAVAILABLE":"503 — Ingestion pipeline is shedding load. Retry with backoff."}}],"endpoint":"grpc://localhost:4317","package":"opentelemetry.proto.collector.logs.v1","proto-url":"https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/logs/v1/logs_service.proto"}]}