OTLP Ingestion¶
Micromegas accepts native OpenTelemetry Protocol (OTLP) traffic over HTTP alongside its custom transit/CBOR wire format. Any OTel-instrumented program — Claude Code, Goose, generic OTel SDKs (Python, Go, JS, .NET, Java) — can point OTEL_EXPORTER_OTLP_ENDPOINT at the ingestion service and have logs, metrics, and spans land in the lakehouse.
Overview¶
The ingestion service exposes the following HTTP ingestion routes. The first three mirror the OpenTelemetry specification directly; the rest accept non-OTLP payloads (Kinesis Firehose deliveries) and translate them internally:
| Route | Payload | Lands in |
|---|---|---|
POST /ingestion/otlp/v1/logs |
ExportLogsServiceRequest |
log_entries |
POST /ingestion/otlp/v1/metrics |
ExportMetricsServiceRequest |
measures |
POST /ingestion/otlp/v1/traces |
ExportTraceServiceRequest |
otel_spans (per-process JIT view) |
POST /ingestion/otlp/v1/metrics/firehose |
ExportMetricsServiceRequest per Firehose record |
measures (see CloudWatch Metric Streams) |
POST /ingestion/cloudwatch/v1/logs/firehose |
CloudWatch Logs subscription-filter record per Firehose record (not OTLP-framed — see CloudWatch Logs) | log_entries |
Routes share the existing listener (default 127.0.0.1:9000) and authentication chain. OTLP payloads are stored as-is in object storage; decoding into parquet rows happens lazily at the analytics layer.
Wire format: OTLP/HTTP with Content-Type: application/x-protobuf or Content-Type: application/json. Optional Content-Encoding: gzip is supported. gRPC OTLP is not supported in the current release.
Quick Start¶
Point an OTel SDK at the ingestion service:
export OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:9000/ingestion/otlp"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
The SDK appends /v1/{logs,metrics,traces} to the base URL per the OTLP spec, so a request lands on http://127.0.0.1:9000/ingestion/otlp/v1/logs. If your operator has set per-signal endpoints (OTEL_EXPORTER_OTLP_LOGS_ENDPOINT), those are full URLs and need to include the /v1/<signal> suffix themselves.
For a production deployment with auth, see Authentication below.
Authentication¶
The OTLP routes share the same auth chain as the rest of the ingestion service: API-key bearer tokens (configured via MICROMEGAS_API_KEYS) and OIDC.
OTel SDKs read OTEL_EXPORTER_OTLP_HEADERS and attach the parsed headers to every export request:
# Server side — same JSON keyring telemetry-ingestion-srv already uses
export MICROMEGAS_API_KEYS='[{"name":"team-platform","key":"mm_abc123def..."}]'
# Client side
export OTEL_EXPORTER_OTLP_ENDPOINT="https://micromegas.example.com/ingestion/otlp"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer mm_abc123def..."
If different signals need different keys, use the per-signal headers variants:
export OTEL_EXPORTER_OTLP_LOGS_HEADERS="Authorization=Bearer key-for-logs"
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer key-for-traces"
Per-signal headers override the catch-all.
TLS in production
Bearer tokens over plaintext leak in transit. Run the listener behind an HTTPS-terminating load balancer (or terminate TLS in-process via axum_server::tls_rustls). Plaintext is fine for localhost development only.
Variable expansion
OTel SDKs do not expand ${VAR} inside OTEL_EXPORTER_OTLP_HEADERS. Your shell expands those at export time. Config-file deployments that read headers from a JSON/YAML file need pre-substituted values or a wrapper script.
Process identity¶
OTLP has no "process" concept; it has a Resource (key/value attributes) attached to each batch. Micromegas synthesizes a stable process_id by hashing the OS-honest identifying tuple together with the OTel service identity:
process_id = uuid_v5(NS_OTEL_PROCESS_V1,
host.id · host.name ·
process.pid · process.creation.time ·
service.namespace · service.name · service.instance.id · process.owner ·
os.type · os.version · os.name · os.description · os.build_id ·
host.arch · host.type ·
host.image.id · host.image.name · host.image.version ·
host.cpu.model.id · host.cpu.model.name · host.cpu.family ·
host.cpu.vendor.id · host.cpu.stepping · host.cpu.cache.l2.size ·
service.version ·
telemetry.sdk.name · telemetry.sdk.language · telemetry.sdk.version ·
process.runtime.name · process.runtime.version · process.runtime.description)
· denotes \x1F (ASCII unit separator). All fields pass through lower-case + trim except
process.pid and process.creation.time which are used verbatim. Missing fields are treated
as empty strings.
The formula was extended in-place under the same NS_OTEL_PROCESS_V1 namespace UUID —
re-deriving existing process_ids is always acceptable, so no namespace bump is needed.
In-flight processes receive a new process_id on their next batch; existing rows are unaffected
and decay under the normal retention policy.
The first time a process_id is observed, a row is inserted into processes with these mappings:
| OTel attribute | Process column |
|---|---|
service.name (or service.namespace + "/" + service.name) |
exe |
host.name |
computer |
user.name |
username / realname |
os.description |
distro |
host.cpu.model.name |
cpu_brand |
process.creation.time (or first event time) |
start_time |
| Everything else | process.properties.otel.resource.* |
tsc_frequency is set to 1_000_000_000 so ticks ≡ Unix nanoseconds — OTel timestamps pass through the existing tick-to-time conversion as identity.
Stream identity¶
One stream per signal per process (max 3 streams per process):
Stream tags reuse the existing micromegas vocabulary:
| Signal | Stream tag | Stream format |
|---|---|---|
| logs | "log" |
otlp/v1/logs |
| metrics | "metrics" |
otlp/v1/metrics |
| traces | "trace" |
otlp/v1/traces |
The streams.format column (added in data-lake schema v4) tells the analytics layer which decoder to use per block; tags carry signal/purpose. log_entries and measures materialize blocks from both native and OTel streams uniformly.
Schema mapping¶
Logs → log_entries¶
| OTel field | parquet column |
|---|---|
time_unix_nano (or observed_time_unix_nano if zero) |
time |
severity_number 1–24 |
level (collapsed to the Micromegas Level enum: TRACE 1–4 → 6, DEBUG 5–8 → 5, INFO 9–12 → 4, WARN 13–16 → 3, ERROR 17–20 → 2, FATAL 21–24 → 1) |
body.string_value |
msg |
body.kvlist_value / array_value |
JSON-stringified into msg |
attributes.* |
properties |
instrumentation_scope.name |
target |
trace_id, span_id |
properties.otel.trace_id / otel.span_id |
severity_text |
properties.otel.severity_text |
Scope identity (name, version, schema_url) and scope attributes land on per-row properties under the otel.scope.* prefix.
Metrics → measures¶
Sum and Gauge data points are materialized directly. Histogram, ExponentialHistogram, and Summary are skipped with a debug log in the current release — they will land in a follow-up that defines a histogram-aware schema.
| OTel field | parquet column |
|---|---|
name |
name |
unit |
unit |
value (int widened to f64) |
value |
time_unix_nano |
time |
aggregation_temporality, is_monotonic, otel.metric.kind |
properties |
Traces → otel_spans¶
otel_spans is a per-process JIT view — query it as view_instance('otel_spans', '<process_id>'). There is no global instance in the current release; cross-process trace traversal requires UNION-ing across the participating processes.
See Schema Reference: otel_spans for the full column list.
Attribute encoding¶
OTel KeyValue.value is an AnyValue oneof. JSONB encoding:
OTel AnyValue variant |
JSONB representation |
|---|---|
string_value |
JSON string |
bool_value |
JSON bool |
int_value (i64) |
JSON number |
double_value |
JSON number (f64) |
bytes_value |
base64-encoded JSON string |
array_value |
JSON array, recursively encoded |
kvlist_value |
JSON object, recursively encoded |
Nested structures are preserved. Query-time access uses the existing jsonb_* UDFs:
SELECT jsonb_as_string(jsonb_get(properties, 'otel.scope.name'))
FROM log_entries
WHERE process_id = '...';
HTTP semantics¶
| Concern | Behavior |
|---|---|
| Body limit | 20 MiB compressed (matches the OTel Collector's default confighttp.max_request_body_size) |
| Compression | Content-Encoding: gzip supported; other codecs return 415 |
| Content-Type | application/x-protobuf or application/json (parameters like ; charset=utf-8 accepted); other types return 415 |
| Empty top-level request | 200 OK with empty Export*ServiceResponse body, no rows written (per spec) |
| Success | 200 OK, response Content-Type mirrors the request encoding; body is an empty Export*ServiceResponse |
| Parse error | 400 Bad Request, body is a google.rpc.Status proto with code = INVALID_ARGUMENT (3) |
| Auth failure | 401 Unauthorized, body is google.rpc.Status |
| Body too large | 413 Payload Too Large, body is google.rpc.Status |
| Unsupported media type | 415 Unsupported Media Type, body is google.rpc.Status |
| Backend transient failure | 503 Service Unavailable with Retry-After: 30 header, body is google.rpc.Status (retryable per spec) |
Per the OTLP spec, error responses always carry a google.rpc.Status proto, not an Export*ServiceResponse.
Idempotency¶
Block IDs are content-addressed: block_id = uuid_v5(NS_OTEL_BLOCK_V1, payload_bytes). Retried POSTs collide on ON CONFLICT (block_id) DO NOTHING and add no rows. This makes the OTLP endpoints safe to retry on transient errors without double-counting.
Client recipes¶
Claude Code¶
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_EXPORTER_OTLP_ENDPOINT="https://micromegas.example.com/ingestion/otlp"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer mm_abc123def..."
# Optional — distributed tracing (Claude Code beta)
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1
export OTEL_TRACES_EXPORTER=otlp
# Optional — multi-team rollups via resource attributes
export OTEL_RESOURCE_ATTRIBUTES="team.id=platform,deployment.environment=prod"
claude
After Claude runs once, verify on the server:
SELECT process_id, exe, computer,
jsonb_as_string(jsonb_get(properties, 'otel.resource.service.instance.id')) AS instance
FROM processes
WHERE jsonb_as_string(jsonb_get(properties, 'otel.resource.service.name')) = 'claude-code'
ORDER BY start_time DESC LIMIT 5;
SELECT count(*) FROM log_entries
WHERE process_id IN (
SELECT process_id FROM processes
WHERE jsonb_as_string(jsonb_get(properties, 'otel.resource.service.name')) = 'claude-code'
);
Python OTel SDK¶
import os
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:9000/ingestion/otlp"
os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
resource = Resource.create({"service.name": "my-service", "service.instance.id": "i-1"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("hello"):
pass
Go OTel SDK¶
export OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:9000/ingestion/otlp"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_SERVICE_NAME="my-service"
Then use otlptracehttp.New(ctx) (or the equivalent for logs/metrics) — it picks up the env vars.
OTLP/JSON & EventBridge API Destinations¶
AWS EventBridge API Destinations send Content-Type: application/json; charset=utf-8 by default, which is accepted by the ingestion server. Use an input transformer to produce the full ExportLogsServiceRequest envelope:
{
"resourceLogs": [{
"resource": { "attributes": [{"key": "service.name", "value": {"stringValue": "<$.source>"}}] },
"scopeLogs": [{
"scope": {"name": "eventbridge"},
"logRecords": [{
"timeUnixNano": "<$.time_ns>",
"severityNumber": 9,
"body": {"stringValue": "<$.detail.message>"}
}]
}]
}]
}
timeUnixNano must be a quoted string in the template (e.g. "<$.time_ns>"). EventBridge input transformers substitute variables as strings inside quotes, satisfying the OTLP/JSON spec requirement. No Lambda translation layer is needed.
Webhook ingestion¶
POST /ingestion/webhook accepts a raw webhook delivery from any header-capable
producer (GitLab, GitHub, a generic SaaS) with no per-source configuration on the
server. It synthesizes an OTLP Resource from three request headers and stores the
request body as a single log record's body, reusing the OTLP logs identity/block/write
path end-to-end — the same auth, body-limit, and idempotency rules described above
apply unchanged. Since log_entries.msg is Utf8-typed, a valid-UTF8 body (the common
case: JSON payloads from GitLab/GitHub/etc.) is stored verbatim. There is no header to
describe an alternate codec, so a non-UTF8 body is stored via lossy UTF-8 conversion
(invalid byte sequences become U+FFFD) rather than rejected or stored as opaque
binary.
| Header | Maps to | Result |
|---|---|---|
X-Micromegas-Service-Name |
resource service.name |
processes.exe / log_entries.exe |
X-Micromegas-Service-Namespace |
resource service.namespace |
folded into exe as namespace/name, and into process_id |
X-Micromegas-Target |
instrumentation scope name | log_entries.target |
All three headers are optional — a missing header behaves like an OTLP resource
that omits the attribute. The body is never parsed or validated server-side; an
empty body returns 400 Bad Request (nothing to store). Content-Type is not
negotiated — send whatever the producer sends (typically application/json).
Because no per-record timestamp is known, time is the server's ingestion wall-clock
time. Retried deliveries dedup via the same content-addressed block_id scheme
described in Idempotency, with two webhook-specific wrinkles:
block_idis hashed from the full incoming header set, not just the 3 recognized ones. OnlyX-Micromegas-Service-Name/-Service-Namespace/-Targetbecome resource attrs, but a producer-specific header this endpoint doesn't otherwise interpret (a GitLab delivery UUID, a GitHub event-type header, a signature) still changesblock_idif it differs — otherwise two unrelated deliveries with byte-identical bodies but different unrecognized headers would collide and dedup as if they were retries of each other. The flip side: a genuine retry that picks up a new value for some header along the way (e.g. a proxy stamping a freshDateor request-id on each hop) is no longer deduped, since that header now participates in the hash too.- The hash is computed before the server backfills the record's timestamp, so the
wall-clock
timewritten on a retry doesn't affectblock_id— otherwise identical deliveries would never dedup, since the backfilled timestamp is different every time.
GitLab example¶
Configure a GitLab group or project webhook to point at the endpoint, with the three custom headers set once in the webhook configuration:
URL: https://micromegas.example.com/ingestion/webhook
Headers: X-Micromegas-Service-Name: gitlab
X-Micromegas-Service-Namespace: my-group
X-Micromegas-Target: gitlab.push
Authorization: Bearer mm_abc123def...
Every push/merge-request/pipeline event GitLab sends lands as one log_entries row
with target = 'gitlab.push', exe = 'my-group/gitlab', and msg equal to the raw
JSON payload GitLab sent.
Querying the stored body¶
The body is opaque JSON text in msg; parse it at query time with the jsonb_* UDFs
(jsonb_parse, jsonb_get, jsonb_as_i64, jsonb_array_length,
jsonb_path_query_first for nested/dotted access — there is no dotted-path variant of
jsonb_get):
SELECT
jsonb_as_string(jsonb_get(jsonb_parse(msg), 'object_kind')) AS kind,
jsonb_as_i64(jsonb_path_query_first(jsonb_parse(msg), '$.object_attributes.iid')) AS iid,
jsonb_array_length(jsonb_get(jsonb_parse(msg), 'commits')) AS nb_commits
FROM log_entries
WHERE target = 'gitlab.push'
ORDER BY time DESC
LIMIT 10;
CloudWatch Metric Streams (Kinesis Firehose)¶
POST /ingestion/otlp/v1/metrics/firehose speaks the Amazon Kinesis Data Firehose HTTP
Endpoint Delivery protocol, so a CloudWatch Metric Stream can push metrics straight into
micromegas: Metric Stream → Firehose → micromegas, with no Lambda, no Kinesis Data
Stream, and no collector process in between. Firehose is just a dumb managed pipe: it
wraps each record in a small JSON envelope and expects a fixed ack shape back.
This works because a Metric Stream configured with OpenTelemetry 1.0.0 output format
delivers each record as an OTLP ExportMetricsServiceRequest protobuf — the exact message
the native /ingestion/otlp/v1/metrics route already decodes. The Firehose route only
unwraps the envelope (gzip-aware, base64 records) and hands each record's bytes to the
same decode/split/write path; records land in measures, same as native OTLP metrics.
Requirement: OpenTelemetry 1.0.0 output format¶
The Metric Stream must be configured with OutputFormat: opentelemetry1.0 (or the
equivalent console option). Other output formats (JSON, Parquet) are not OTLP and are not
supported by this endpoint.
AWS delivery-stream setup¶
Configure a Kinesis Firehose delivery stream with an HTTP endpoint destination:
- HTTP endpoint URL:
https://micromegas.example.com/ingestion/otlp/v1/metrics/firehose - Access key: a micromegas API key — the value from
MICROMEGAS_API_KEYS— sent by Firehose asX-Amz-Firehose-Access-Keyon every request (Firehose cannot sendAuthorization: Bearer, so this route authenticates via that header instead, reusing the same keyring check as every other ingestion route). - Content encoding: gzip (recommended — reduces wire bytes; the route decompresses transparently, same as the other OTLP routes).
- Buffering hints: tune buffer size/interval for your metric volume; every buffered batch arrives as one HTTP POST carrying one JSON record per underlying Metric Stream record.
- S3 backup: configure "backup all records" or "backup failed data only" — Firehose retries non-200 responses and eventually spills to the configured S3 bucket, so no data is silently lost even during an extended micromegas outage.
Then point a CloudWatch Metric Stream at the delivery stream, with output format set to OpenTelemetry 1.0.0.
Ack contract¶
Success is 200 OK with Content-Type: application/json and body:
Any non-200 status triggers a Firehose retry, and body:
requestId always echoes the X-Amz-Firehose-Request-Id header — this is required by the
Firehose HTTP Endpoint Delivery contract.
TLS in production
Same as the Bearer OTLP routes: X-Amz-Firehose-Access-Key over plaintext leaks in
transit. Terminate TLS in front of the listener for any production delivery stream.
Idempotency¶
Same content-addressed block_id scheme as the rest of OTLP ingestion (see
Idempotency): a Firehose retry of a previously-succeeded batch
re-computes identical block_ids and dedups on write. On a partial batch failure,
Firehose retries the whole batch — already-written records dedup, the failed one is
retried. CloudWatch Metric Streams stamp distinct timestamps per scrape, so genuinely
distinct data never collides.
CloudWatch Logs (Kinesis Firehose)¶
POST /ingestion/cloudwatch/v1/logs/firehose speaks the same Amazon Kinesis Data
Firehose HTTP Endpoint Delivery protocol as the metrics route above, but for
CloudWatch Logs subscription filters: CloudWatch Logs → subscription filter →
Firehose → micromegas, with no Lambda, no Kinesis Data Stream, and no collector process
in between.
Unlike the metrics route, this one is not OTLP-framed on the wire — CloudWatch Logs
subscription-filter delivery has exactly one proprietary record format, gzip-compressed
regardless of the delivery stream's own Content-Encoding setting. Once decoded,
micromegas synthesizes an OTLP ExportLogsServiceRequest internally (one Resource, one
LogRecord per logEvent) and feeds it through the same logs split/write path as native
OTLP logs — so log_entries sees these rows exactly like any other log producer.
Payload format¶
Each Firehose record's data, after base64-decode, is gzip-compressed. Decompressed, it
is CloudWatch's subscription-filter JSON:
{
"messageType": "DATA_MESSAGE",
"owner": "123456789012",
"logGroup": "/ecs/my-service",
"logStream": "ecs/my-service/abcd1234",
"subscriptionFilters": ["my-filter"],
"logEvents": [
{ "id": "...", "timestamp": 1510109208016, "message": "raw log line" }
]
}
CONTROL_MESSAGE records — which CloudWatch sends periodically to verify
reachability — are recognized and dropped silently (not an error, no row written, no
process registered).
AWS delivery-stream setup¶
Configure a Kinesis Firehose delivery stream with an HTTP endpoint destination, subscribed from a CloudWatch Logs log group via a subscription filter:
- HTTP endpoint URL:
https://micromegas.example.com/ingestion/cloudwatch/v1/logs/firehose - Access key: a micromegas API key — the value from
MICROMEGAS_API_KEYS— sent by Firehose asX-Amz-Firehose-Access-Keyon every request, same as the metrics route. - Content encoding: CloudWatch always gzips each record's payload at the source; this
is independent of (and unaffected by) any additional
Content-Encoding: gzipFirehose itself may apply to the whole HTTP body — both layers are handled transparently. - S3 backup: same recommendation as the metrics route — Firehose retries non-200 responses and eventually spills to S3, so no data is silently lost during an extended micromegas outage.
How logGroup/logStream/owner surface¶
service.name=logGroup,service.instance.id=logStream— feeds the sameprocess_id_from_resourceidentity formula every other OTLP/OTel producer uses, so distinct log streams (distinct ECS tasks, Lambda instances, RDS instances) resolve to distinctprocess_ids with no CloudWatch-specific identity logic.logGroup,logStream, andowner(AWS account id) are all set as resource attributes (aws.log.group.name,aws.log.stream.name,cloud.account.id), so they surface per-row viaprocess_properties.otel.resource.*— the same discovery path as any other OTel resource attribute (see Process identity above).- The per-event CloudWatch
idis attached as a record-level attribute (aws.log.event.id), queryable viaproperties, letting you correlate alog_entriesrow back to the exact CloudWatch event.
Cross-account collisions
cloud.account.id (owner) is not part of the process_id identity hash, so two
different AWS accounts with the same logGroup+logStream names collapse onto the
same process_id. Rows remain unambiguous (owner is still queryable per-row), only
the process grouping is coarser than ideal across accounts — most relevant for RDS
Postgres logs, where stream names are user-chosen DB-instance identifiers that can
repeat across environments.
Ack contract¶
Same as CloudWatch Metric Streams: 200 OK with
{"requestId": "<echoed>", "timestamp": ...} on success; any non-200 status (with
errorMessage) triggers a Firehose retry.
Idempotency¶
Same content-addressed block_id scheme as the rest of OTLP ingestion (see
Idempotency): a Firehose retry of a previously-succeeded batch dedups on
write. CloudWatch Logs events carry real per-event timestamps (no backfill), so genuinely
distinct log lines never collide.
TLS in production
Same as the metrics Firehose route: X-Amz-Firehose-Access-Key over plaintext leaks
in transit. Terminate TLS in front of the listener for any production delivery stream.
Limitations¶
- OTLP/HTTP only. gRPC OTLP is not implemented; SDKs that default to gRPC need
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf. - OTLP/JSON: string-encoded 64-bit fields required. The OTLP/JSON spec mandates
"timeUnixNano"and similar 64-bit integer fields as quoted strings (e.g."1700000000000000000"). Bare JSON numbers are rejected. Conformant OTel SDKs and EventBridge input transformers produce the string form automatically. - No mTLS / client certs. Only bearer-token and OIDC auth.
- Histograms not yet materialized. Sum and Gauge land in
measures; Histogram, ExponentialHistogram, and Summary are skipped with a debug log. otel_spansis JIT-only and per-process. Cross-process trace queries (WHERE trace_id = Xacross all services) need to UNION across each participating process.parse_blockdoes not decode OTel payloads. It returns a clean error onformat != "micromegas-transit".- No per-tenant rate limiting. Add at the load balancer if needed.
Troubleshooting¶
415 Unsupported Media Type — the SDK is sending an unsupported Content-Type or omitting it entirely. Accepted types are application/x-protobuf and application/json. Other compression codecs (deflate, zstd) also return 415; only gzip is accepted.
401 Unauthorized — verify the bearer token matches an entry in MICROMEGAS_API_KEYS on the server. Check that the SDK is actually attaching the header (OTEL_EXPORTER_OTLP_HEADERS is processed at export time, not at SDK init — typos are silently ignored).
413 Payload Too Large — the compressed body exceeds 20 MiB. Lower the SDK's batch size (OTEL_BSP_MAX_EXPORT_BATCH_SIZE, OTEL_BLRP_MAX_EXPORT_BATCH_SIZE) or split into more frequent exports.
Process collapses across runs — the formula expects service.instance.id to vary per OS process. If your SDK omits it (some FaaS configurations), every invocation hashes to the same process_id. Set it explicitly via OTEL_RESOURCE_ATTRIBUTES=service.instance.id=$(uuidgen) or have the SDK generate one.
process_id looks identical across very different services — host.id, host.name, process.pid, and service.instance.id all came back empty. Check the resource detector configuration on the SDK side; the server logs a degenerate-resource warning when this happens.
Logs without an explicit severity appear with level = 4 (Info) — severity_number = 0 (UNSPECIFIED) maps to Info so unspecified records pass the default WHERE level <= 4 filter (lower number = more severe in micromegas; level <= 4 keeps Info-and-more-severe). Set severity_number explicitly on the SDK side if you want a different mapping.
Trace queries return nothing — otel_spans is a JIT view and only materializes when queried with a specific process_id. Use view_instance('otel_spans', '<process_id>'), not FROM otel_spans. Find the right process_id via the processes view first.