Send data from any OpenTelemetry SDK
Point any OpenTelemetry SDK or Collector at Unhandled over OTLP/HTTP, with the endpoint paths, headers, encodings, limits and error codes.
unhandled accepts standard OTLP, so any OpenTelemetry SDK or Collector can send it traces, logs and metrics. This page is the wire contract: the endpoints, the authentication headers, what the server accepts and how it reads what you send. It also has setup code for Python, Go, Ruby, Java and .NET. In Node.js, the Node.js SDK and its framework packages configure all of this for you, and so does the AdonisJS agent.
Endpoints
Send POST requests to the origin of your DSN:
| Signal | Path |
|---|---|
| Traces | /v1/traces |
| Logs | /v1/logs |
| Metrics | /v1/metrics |
| Request part | Accepted values |
|---|---|
content-type | application/x-protobuf, application/protobuf or application/json |
content-encoding | gzip, or none |
| Body size | 10 MB as sent, 16 MB after gzip decompression |
| Items | 10,000 per table per request, 128 attributes per item. See ingest limits |
Use protobuf for server apps. It is smaller, and the official exporters default to it. JSON suits hand-written senders and browsers. Turn gzip on.
The DSN
Each service has its own DSN:
https://[email protected]/0b6f3c1e-8d2a-4f7b-9c5e-2a1d4e6f8b3c| DSN part | Becomes |
|---|---|
| Scheme, host and port | The ingest endpoint. Append the signal path to it: https://ingest.example.com/v1/traces. |
| Username | The API key, uh_live_... or uh_test_..., sent as x-api-key. |
| Path | The service id, a UUID, sent as x-unhandled-service-id without the leading /. |
A DSN with a password, a query string or a fragment is invalid. The DSN carries no environment. You choose it per deployment.
Headers
Every request needs all three:
x-api-keystringrequiredThe API key from the DSN username.
x-unhandled-service-iduuidrequiredThe service id from the DSN path.
x-unhandled-envproduction | staging | developmentrequiredThe environment to file the data under.
The environment comes only from x-unhandled-env. The server overwrites any deployment.environment.name resource attribute with the header value.
Connect your SDK
Pick your stack. Each example splits UNHANDLED_DSN into the endpoint and the headers as described above.
The Python, Go and Ruby tabs show the telemetry file of an example app in the Unhandled repository, under examples/python, examples/go and examples/ruby. Each one reads UNHANDLED_DSN and UNHANDLED_ENV, sets service.name and service.version, and exports over OTLP/HTTP with protobuf and gzip. CI runs each app against a real ingest server and checks that its routes, users, queries, exceptions and release arrive. The Java and .NET tabs use the standard OTEL_* variables and are not tested in CI.
Without code. The Node auto-instrumentation package reads the standard OTEL_* variables. Set the variables from the Any OTel SDK tab, then:
npm install @opentelemetry/api @opentelemetry/auto-instrumentations-nodepnpm add @opentelemetry/api @opentelemetry/auto-instrumentations-nodeyarn add @opentelemetry/api @opentelemetry/auto-instrumentations-nodenode --import @opentelemetry/auto-instrumentations-node/register server.jsIn code. @unhandled/node builds this setup for you. If you build the NodeSDK yourself, @unhandled/protocol parses the DSN and builds the headers, so UNHANDLED_DSN is the only secret you keep.
npm install @unhandled/protocol @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-proto @opentelemetry/exporter-logs-otlp-proto @opentelemetry/exporter-metrics-otlp-proto @opentelemetry/sdk-logs @opentelemetry/sdk-metrics @opentelemetry/resourcespnpm add @unhandled/protocol @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-proto @opentelemetry/exporter-logs-otlp-proto @opentelemetry/exporter-metrics-otlp-proto @opentelemetry/sdk-logs @opentelemetry/sdk-metrics @opentelemetry/resourcesyarn add @unhandled/protocol @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-proto @opentelemetry/exporter-logs-otlp-proto @opentelemetry/exporter-metrics-otlp-proto @opentelemetry/sdk-logs @opentelemetry/sdk-metrics @opentelemetry/resourcesimport { NodeSDK } from '@opentelemetry/sdk-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto'
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { OTLP_PATHS, exportHeaders, parseDsn } from '@unhandled/protocol'
const parsed = parseDsn(process.env.UNHANDLED_DSN ?? '')
if (!parsed.ok) {
throw new Error(`Invalid UNHANDLED_DSN: ${parsed.error.code}`)
}
const { dsn } = parsed
const headers = exportHeaders(dsn, 'production')
const sdk = new NodeSDK({
resource: resourceFromAttributes({
'service.name': 'orders-api',
'service.version': process.env.RELEASE ?? 'unknown',
}),
traceExporter: new OTLPTraceExporter({ url: dsn.endpoint + OTLP_PATHS.traces, headers }),
logRecordProcessors: [
new BatchLogRecordProcessor(
new OTLPLogExporter({ url: dsn.endpoint + OTLP_PATHS.logs, headers })
),
],
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: dsn.endpoint + OTLP_PATHS.metrics, headers }),
}),
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()Load the file before the rest of the app, for example with node --import ./otel.js server.js. The instrumentations have to patch modules before your code imports them.
This is telemetry.py from examples/python. It exports traces and logs.
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http opentelemetry-instrumentation-flaskimport logging
import os
from dataclasses import dataclass
from urllib.parse import unquote, urlsplit
from opentelemetry import trace
from opentelemetry._logs import set_logger_provider
from opentelemetry.exporter.otlp.proto.http import Compression
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
SERVICE_NAME = "otel-python"
SERVICE_VERSION = "1.4.2"
@dataclass(frozen=True)
class Dsn:
endpoint: str
api_key: str
service_id: str
def parse_dsn(value: str) -> Dsn:
parts = urlsplit(value)
if not parts.username or not parts.hostname or not parts.path.strip("/"):
raise ValueError("UNHANDLED_DSN must look like https://<api key>@<host>/<service id>")
port = f":{parts.port}" if parts.port else ""
return Dsn(
endpoint=f"{parts.scheme}://{parts.hostname}{port}",
api_key=unquote(parts.username),
service_id=parts.path.strip("/"),
)
@dataclass(frozen=True)
class Telemetry:
tracer_provider: TracerProvider
logger_provider: LoggerProvider
def shutdown(self) -> None:
self.tracer_provider.force_flush()
self.logger_provider.force_flush()
self.tracer_provider.shutdown()
self.logger_provider.shutdown()
def setup_telemetry() -> Telemetry:
dsn = parse_dsn(os.environ["UNHANDLED_DSN"])
headers = {
"x-api-key": dsn.api_key,
"x-unhandled-service-id": dsn.service_id,
"x-unhandled-env": os.environ.get("UNHANDLED_ENV", "development"),
}
resource = Resource.create({"service.name": SERVICE_NAME, "service.version": SERVICE_VERSION})
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint=f"{dsn.endpoint}/v1/traces", headers=headers, compression=Compression.Gzip
)
)
)
trace.set_tracer_provider(tracer_provider)
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(
endpoint=f"{dsn.endpoint}/v1/logs", headers=headers, compression=Compression.Gzip
)
)
)
set_logger_provider(logger_provider)
logging.getLogger().addHandler(LoggingHandler(logger_provider=logger_provider))
logging.getLogger().setLevel(logging.INFO)
return Telemetry(tracer_provider, logger_provider)Call setup_telemetry() before you create the app, then instrument Flask with FlaskInstrumentor().instrument_app(app). The LoggingHandler sends standard logging records. logger.exception() adds exception.* attributes, and Unhandled turns the record into an exception occurrence. Call shutdown() on the returned Telemetry before the process exits, so the last batch goes out.
Without code. Not tested in CI. Install the distro and the OTLP/HTTP exporter, then let opentelemetry-bootstrap add the instrumentations for the libraries you have installed:
pip install opentelemetry-distro opentelemetry-exporter-otlp-proto-http
opentelemetry-bootstrap -a installSet the variables from the Any OTel SDK tab and add one more, which turns on log export for the logging module:
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=trueopentelemetry-instrument python app.pyThis is telemetry.go from examples/go. It exports traces and logs.
go get go.opentelemetry.io/otel go.opentelemetry.io/otel/sdk go.opentelemetry.io/otel/sdk/log \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp \
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \
go.opentelemetry.io/contrib/bridges/otelslogpackage main
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"strings"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/log/global"
sdklog "go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
)
const (
serviceName = "otel-go"
serviceVersion = "2.0.1"
)
type dsn struct {
endpoint *url.URL
apiKey string
serviceID string
}
func parseDSN(raw string) (dsn, error) {
parsed, err := url.Parse(raw)
if err != nil {
return dsn{}, err
}
serviceID := strings.Trim(parsed.Path, "/")
if parsed.User.Username() == "" || parsed.Host == "" || serviceID == "" {
return dsn{}, errors.New("UNHANDLED_DSN must look like https://<api key>@<host>/<service id>")
}
return dsn{
endpoint: &url.URL{Scheme: parsed.Scheme, Host: parsed.Host},
apiKey: parsed.User.Username(),
serviceID: serviceID,
}, nil
}
type telemetry struct {
tracerProvider *sdktrace.TracerProvider
loggerProvider *sdklog.LoggerProvider
}
func (t telemetry) shutdown(ctx context.Context) error {
return errors.Join(t.tracerProvider.Shutdown(ctx), t.loggerProvider.Shutdown(ctx))
}
func setupTelemetry(ctx context.Context) (telemetry, error) {
target, err := parseDSN(os.Getenv("UNHANDLED_DSN"))
if err != nil {
return telemetry{}, err
}
environment := os.Getenv("UNHANDLED_ENV")
if environment == "" {
environment = "development"
}
headers := map[string]string{
"x-api-key": target.apiKey,
"x-unhandled-service-id": target.serviceID,
"x-unhandled-env": environment,
}
res, err := resource.Merge(resource.Default(), resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
))
if err != nil {
return telemetry{}, err
}
spanExporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpointURL(target.endpoint.JoinPath("v1", "traces").String()),
otlptracehttp.WithHeaders(headers),
otlptracehttp.WithCompression(otlptracehttp.GzipCompression),
)
if err != nil {
return telemetry{}, fmt.Errorf("trace exporter: %w", err)
}
logExporter, err := otlploghttp.New(ctx,
otlploghttp.WithEndpointURL(target.endpoint.JoinPath("v1", "logs").String()),
otlploghttp.WithHeaders(headers),
otlploghttp.WithCompression(otlploghttp.GzipCompression),
)
if err != nil {
return telemetry{}, fmt.Errorf("log exporter: %w", err)
}
tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(spanExporter),
sdktrace.WithResource(res),
)
loggerProvider := sdklog.NewLoggerProvider(
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter)),
sdklog.WithResource(res),
)
otel.SetTracerProvider(tracerProvider)
global.SetLoggerProvider(loggerProvider)
return telemetry{tracerProvider, loggerProvider}, nil
}Wrap your mux with otelhttp.NewHandler(mux, "http.server"). otelhttp names the span after r.Pattern but leaves http.route unset. Set it in each handler, or requests group by raw path:
_, route, _ := strings.Cut(r.Pattern, " ")
trace.SpanFromContext(r.Context()).SetAttributes(semconv.HTTPRoute(route))otelhttp does not record panics. Record errors on the span with span.RecordError(err, trace.WithStackTrace(true)), or log them through the otelslog bridge with an error attribute, which becomes exception.* attributes. Call shutdown(ctx) on the returned telemetry before the process exits.
This is telemetry.rb from examples/ruby. It exports traces and logs.
gem "opentelemetry-sdk"
gem "opentelemetry-exporter-otlp"
gem "opentelemetry-logs-sdk"
gem "opentelemetry-exporter-otlp-logs"
gem "opentelemetry-instrumentation-sinatra"require "uri"
require "sinatra/base"
require "opentelemetry/sdk"
require "opentelemetry/exporter/otlp"
require "opentelemetry-logs-sdk"
require "opentelemetry/exporter/otlp_logs"
require "opentelemetry/instrumentation/sinatra"
module Telemetry
SERVICE_NAME = "otel-ruby"
SERVICE_VERSION = "3.1.0"
Dsn = Data.define(:endpoint, :api_key, :service_id) do
def self.parse(value)
uri = URI(value)
service_id = uri.path.delete_prefix("/")
if uri.user.to_s.empty? || uri.host.to_s.empty? || service_id.empty?
raise ArgumentError, "UNHANDLED_DSN must look like https://<api key>@<host>/<service id>"
end
new(endpoint: "#{uri.scheme}://#{uri.host}:#{uri.port}", api_key: URI.decode_www_form_component(uri.user), service_id:)
end
end
def self.setup
dsn = Dsn.parse(ENV.fetch("UNHANDLED_DSN"))
headers = {
"x-api-key" => dsn.api_key,
"x-unhandled-service-id" => dsn.service_id,
"x-unhandled-env" => ENV.fetch("UNHANDLED_ENV", "development")
}
OpenTelemetry::SDK.configure do |config|
config.resource = OpenTelemetry::SDK::Resources::Resource.create(
"service.name" => SERVICE_NAME,
"service.version" => SERVICE_VERSION
)
config.add_span_processor(
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint: "#{dsn.endpoint}/v1/traces", headers:, compression: "gzip")
)
)
config.add_log_record_processor(
OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(
OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(endpoint: "#{dsn.endpoint}/v1/logs", headers:, compression: "gzip")
)
)
config.use "OpenTelemetry::Instrumentation::Sinatra"
end
end
def self.shutdown
OpenTelemetry.tracer_provider.shutdown
OpenTelemetry.logger_provider.shutdown
end
endCall Telemetry.setup before your app class loads. The Sinatra instrumentation creates the server spans and records unhandled errors on them. The Ruby logs SDK has no bridge for the standard Logger. Emit handled errors through the logs API with exception.type, exception.message and exception.stacktrace attributes. Call Telemetry.shutdown before the process exits.
Not tested in CI. The OpenTelemetry Java agent reads the standard variables and instruments common libraries without code changes:
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.example.com
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=uh_live_7Hq2xR9vLp3sKd8mWn4tYb6c,x-unhandled-service-id=0b6f3c1e-8d2a-4f7b-9c5e-2a1d4e6f8b3c,x-unhandled-env=production
OTEL_SERVICE_NAME=orders-api
OTEL_RESOURCE_ATTRIBUTES=service.version=2.4.0java -javaagent:opentelemetry-javaagent.jar -jar orders-api.jarThe agent sends traces, logs and metrics, and sets http.route on server spans for the web frameworks it supports. Unhandled parses Java stack traces into frames. It treats frames from java.*, javax.*, jdk.*, sun.*, com.sun.*, kotlin.*, kotlinx.* and scala.* as library code.
Not tested in CI. Set the same variables as for Java, without the compression line:
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.example.com
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=uh_live_7Hq2xR9vLp3sKd8mWn4tYb6c,x-unhandled-service-id=0b6f3c1e-8d2a-4f7b-9c5e-2a1d4e6f8b3c,x-unhandled-env=production
OTEL_SERVICE_NAME=orders-api
OTEL_RESOURCE_ATTRIBUTES=service.version=2.4.0The .NET exporter defaults to gRPC, so OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf is required. In an ASP.NET Core app, add the OpenTelemetry.Extensions.Hosting, OpenTelemetry.Exporter.OpenTelemetryProtocol, OpenTelemetry.Instrumentation.AspNetCore and OpenTelemetry.Instrumentation.Http packages. Then register the exporters without arguments so they read the variables:
using OpenTelemetry.Logs;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter())
.WithLogging(logging => logging.AddOtlpExporter());
var app = builder.Build();ILogger calls that pass an exception arrive as log records with exception.* attributes and become occurrences. Unhandled does not parse .NET stack traces into frames yet, so their issues group by exception type and message.
Environment variables. Most OpenTelemetry SDKs read the standard OTEL_* variables, so you can often connect with no code. Split the DSN into the endpoint and the headers:
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.example.com
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=uh_live_7Hq2xR9vLp3sKd8mWn4tYb6c,x-unhandled-service-id=0b6f3c1e-8d2a-4f7b-9c5e-2a1d4e6f8b3c,x-unhandled-env=production
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_SERVICE_NAME=orders-api
OTEL_RESOURCE_ATTRIBUTES=service.version=2.4.0,vcs.ref.head.revision=3f9c2a7d41b08e5f6a1c9d2e7b4f8a0c5d3e1b29,service.instance.id=orders-api-1The exporters append /v1/traces, /v1/logs and /v1/metrics to OTEL_EXPORTER_OTLP_ENDPOINT themselves. Do not add the path.
Collector. With an OpenTelemetry Collector in front of your apps, add an otlphttp exporter:
exporters:
otlphttp/unhandled:
endpoint: https://ingest.example.com
compression: gzip
headers:
x-api-key: ${env:UNHANDLED_API_KEY}
x-unhandled-service-id: 0b6f3c1e-8d2a-4f7b-9c5e-2a1d4e6f8b3c
x-unhandled-env: production
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlphttp/unhandled]
logs:
receivers: [otlp]
exporters: [otlphttp/unhandled]
metrics:
receivers: [otlp]
exporters: [otlphttp/unhandled]The headers tie every request to one service. When one Collector serves several services, run one exporter per service.
How Unhandled reads your data
- Service. Data always lands in the service named by
x-unhandled-service-id. The server overwrites theservice.nameresource attribute with that service's telemetry name. - Environment. Read from
x-unhandled-envonly. - Release.
service.versionbecomes the release shown on traces and issues. Unhandled ignores the placeholders0.0.0,0.0.1,1.0.0,unknown,devandlatest. When a new version takes over a service's entry spans, Unhandled records a deployment. Sendvcs.ref.head.revisionwith the full 40-character commit SHA, above all when the version is a semver tag, so the deployment links to its commit. - Instance.
service.instance.id, orhost.namewhen it is missing, identifies the process for system metrics. - Reserved attributes. The server sets
unhandled.organization.id,unhandled.project.id,unhandled.environmentandunhandled.service.id, and overwrites any values you send. - Legacy names. The server renames older attributes such as
http.methodanddb.statementto their current names.
No resource attribute is required, but the views key off attribute names. Semantic conventions lists what each one drives.
What each view reads
The views do not need an Unhandled SDK. Each one reads an OpenTelemetry semantic convention where one exists, and a documented attribute where none does.
| View | Read from | Notes |
|---|---|---|
| Requests | SERVER spans with http.request.method, grouped by http.route | Without http.route, Unhandled uses url.path with identifiers replaced by :id. Old names such as http.method and http.target are renamed at ingest. |
| Jobs | Spans with messaging.system | Named by messaging.destination.name when no Unhandled job name is set. PRODUCER spans, or a messaging.operation.type of send, publish or create, count as dispatches. Old messaging.operation and messaging.destination are renamed. |
| Commands | A span with unhandled.entry_point=cli, or an INTERNAL span with the span attribute process.executable.name | Named by cli.command.name, then process.command, then process.executable.name, then the span name. A resource-level process.executable.name does not count. |
| Users | user.id, user.email and user.name on SERVER spans | enduser.id is renamed to user.id, and enduser.role to user.roles. |
| Exceptions | Span events named exception, and log records with exception.type or exception.message | Unhandled parses exception.stacktrace for Python, Go, Ruby, Java and JavaScript. A log record becomes an occurrence with mechanism log_record and handled: true, and its level comes from the record's severity. |
| Deployments | The service.version resource attribute | vcs.ref.head.revision links the release to its commit. Placeholders such as 0.0.0 are ignored. |
| Emails | Spans with email.system | No semantic convention exists. Set email.system, plus email.transport and email.message.subject for the breakdowns. |
| Analytics | Log records from the logger named unhandled.analytics | No semantic convention exists. The record body follows the shape on the analytics page. |
Distributed tracing
Keep the W3C traceparent propagator so a request that crosses services stays in one trace. It is the default in most SDKs. If you add other propagators, keep W3C in the list.
Unhandled also reads the OTLP span flags that mark a span's parent as remote. Recent JavaScript exporters send them. With the flags, a span with a remote parent starts a new service segment. Without them, Unhandled falls back to the span kind, and SERVER and CONSUMER spans start a segment.
Responses and errors
A successful request gets 200, encoded like the request. The 200 means the server stored the raw request. Unhandled turns it into spans, logs and metrics afterwards, so the data shows up shortly after the response. When some records are unusable, such as a span with an invalid trace or span id, the response carries an OTLP partialSuccess with the rejected count and up to 5 error messages. Unhandled stores the rest of the batch.
Errors return JSON { "code": <status>, "message": "..." }:
| Status | Cause |
|---|---|
400 | x-unhandled-env is missing or invalid, the body is cut off or is not valid gzip, or the payload does not decode. |
401 | x-api-key or x-unhandled-service-id is missing, or the key is invalid, revoked or expired. |
403 | The key belongs to another service, the organization is suspended, the key's organization does not own the ingest domain you sent to, or the request Origin is not allowed for this service. |
405 | The method is not POST or OPTIONS. |
408 | The body did not arrive within 30 seconds. |
413 | The body is over 10 MB, or over 16 MB after decompression. |
415 | Unsupported content-type or content-encoding. |
429 | The service sent more requests per second than allowed, the organization sent more bytes than its plan's rate, the organization reached its hard cap for the billing period, or the ingest queue is full. Retry after the retry-after delay. |
503 | Storage or the API key lookup is unavailable, or the server is at its concurrent request limit. Retry after the retry-after delay. |
Browsers can call the endpoints directly. The server answers preflight OPTIONS requests with 204 and allows content-type, content-encoding and the three headers above. When a service has an allowed-origins list, requests from other origins get 403. The browser SDK handles this for you.
Common questions
No. Unhandled only accepts OTLP over HTTP. Set the protocol to http/protobuf and use the DSN origin as the endpoint.
The service comes from x-unhandled-service-id, and the server replaces service.name with that service's name.
A rotated key keeps working until the grace period you picked ends. A revoked key stops working at once. The server caches each key check for 60 seconds and drops the cache when a key is revoked or expires, so a revoked key keeps working for up to a minute only while the server cannot receive change notifications from the database.
Yes, with one otlphttp exporter per service, each with its own x-unhandled-service-id and key.