Observability
Puddle reports through its logs and, with
telemetry enabled,
through OpenTelemetry: a management listener serves Prometheus
/metrics, and traces go over OTLP to a collector. The three share
ids: a log line carries the request_id and the trace_id, and the
request’s span carries the request_id.
To see all of it running on your machine, start the observability example: puddle, an OpenTelemetry collector, Jaeger, Prometheus and Grafana with a starter dashboard.
Logs
Logs go to stderr, as text or JSON:
logging:
level: info # debug | info | warn | error
format: json # text (default) | jsonUse json wherever a log pipeline parses the lines. info is the
level for production; debug adds the authorization probes below.
Request ids
Every response carries an X-Request-Id header, and every log line
about a request carries the same id as request_id. An inbound
X-Request-Id (printable ASCII, at most 128 bytes) is kept, so the id
your proxy or client sets threads through; otherwise puddle generates
one (a UUIDv7). Error responses carry it in the error body too, and a
5xx message names it, since that’s all the client learns about the
failure. To investigate a client’s error, search the logs for its
request id. See Request ids and
errors.
With telemetry on, a log line written during a traced request also
carries trace_id and span_id, and the request’s server span has
the request id as puddle.request.id: from a client’s error you can
go to the log lines and on to the trace.
What gets logged
| Message | Level | When |
|---|---|---|
request | info | every catalog request, with op (the operation, such as loadTable or updateTable), method, path, warehouse, status and duration_ms; a table load that sent X-Iceberg-Access-Delegation also has access_delegation |
authz decision | info | every authorization decision, with event="authz.decision", decision (allow/deny), action, principal_sub, principal_issuer, resources and reason |
authz probe | debug | the checks that choose between read-only and read-write vended credentials (event="authz.probe") |
authz cel expression failed | info | with authz.mode: cel, an evaluation error (a missing claim, the cost limit), which denies: event="authz.cel.error", action, resource_kind and err; debug for a probe |
authn rejected | info | a request without a valid token, with the reason |
request failed | error | a 5xx: the full error, with op, method, path, warehouse and principal |
bad request | info | a malformed request body, with the decoder’s error |
metrics report | info | a scan or commit report an engine sent (/metrics), with the table, the report-type and the body’s size (body_bytes) |
readiness check failed | error | /readyz failed, with the metastore’s error |
purge: … | info/warn | the end of each purge, and files it skipped or couldn’t delete (see Timeouts and purge) |
not vending credentials: … | warn | a table load that got no storage credentials, with the reason |
authentication is disabled | warn | at startup, when puddle runs without authn: |
opentelemetry error | warn | the SDK couldn’t export spans (the collector is unreachable, say) |
The authz decision lines are an audit log of who did what: route
them by event="authz.decision" to wherever audit records go
(Authorization has
the actions and resources they name). They
are logged for every mode, including allow-authenticated. With OPA,
OPA’s own decision logs record the full input and result of each
query as well.
The request line’s duration_ms is the time from the request’s
arrival to its response (in milliseconds, with microsecond
precision), without the time spent sending the body.
Enabling telemetry
Telemetry is off by default: no SDK, no second listener, nothing sent anywhere. Turn it on in the config:
telemetry:
enabled: true
addr: ":9090" # the management listener; not server.addr's portThat gives you /metrics on the management listener. Traces need an
exporter as well, and everything about them comes from the standard
OpenTelemetry environment variables:
| Variable | What it does in puddle |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | where to send traces, such as http://otel-collector:4318. Setting it (or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) is what turns trace export on; without it puddle exports nothing, rather than the SDK’s default of trying localhost |
OTEL_EXPORTER_OTLP_PROTOCOL | http/protobuf (the default; port 4318) or grpc (4317) |
OTEL_EXPORTER_OTLP_HEADERS | headers for the collector, such as authorization=Bearer …; never logged or recorded |
OTEL_EXPORTER_OTLP_CERTIFICATE, _TIMEOUT, _COMPRESSION | the exporter’s usual TLS, timeout and compression settings |
OTEL_TRACES_EXPORTER | otlp (to the endpoint, or the spec’s localhost default) or none (no traces, even with an endpoint set) |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG | the sampler; see Sampling |
OTEL_SERVICE_NAME | the service.name (default puddle) |
OTEL_RESOURCE_ATTRIBUTES | more resource attributes, such as deployment.environment.name=prod,k8s.namespace.name=data |
OTEL_BSP_SCHEDULE_DELAY, OTEL_BSP_MAX_QUEUE_SIZE, … | the batch span processor (the default is a batch every 5 s, at most 2048 spans queued) |
OTEL_SDK_DISABLED | true turns the SDK off whatever the config says; the management listener still answers the probes, with an empty /metrics, and a warning is logged |
The resource always has service.name, service.version (puddle’s
version) and, when the build knows it, vcs.ref.head.revision (the
commit). On /metrics they are the labels of target_info.
A malformed value (an unknown sampler or exporter, a ratio outside
0–1, an endpoint that isn’t an http:// or https:// URL) stops
puddle at boot. Puddle never waits for a collector: the exporter
connects when it first has spans to send, failed exports are logged
as opentelemetry error and retried by the SDK, and spans still
queued at shutdown are dropped once server.shutdown-timeout runs
out. An unreachable collector never fails a request or the exit code.
A Kubernetes container with telemetry on:
containers:
- name: puddle
args: ["-c", "/etc/puddle/puddle.yaml"]
ports:
- { name: http, containerPort: 8089 }
- { name: metrics, containerPort: 9090 }
env:
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector.observability:4318
- name: OTEL_TRACES_SAMPLER_ARG
value: "0.05"
- name: OTEL_RESOURCE_ATTRIBUTES
value: deployment.environment.name=prodThe management listener
With telemetry on, puddle opens a second listener on telemetry.addr
that serves:
GET /metrics: the metrics below, in the Prometheus text format;GET /healthzandGET /readyz: the same probes as the catalog listener.
It has the server block’s timeouts. At shutdown it keeps answering
while the catalog listener drains, so the last scrape and the probes
see the drain; then the telemetry is flushed, all within
server.shutdown-timeout. If its port is taken, puddle doesn’t
start.
The management listener isn’t instrumented, and neither are the
probes, the root page, /docs or /assets on the catalog listener:
the metrics and traces are about catalog requests only.
Securing the management listener
Everything on it is unauthenticated, by design: Prometheus and the kubelet don’t carry tokens. Nothing on it names a namespace, table, principal or location, but the metrics do show how busy each warehouse is and what fails. So:
- give it a port of its own and don’t publish it: no
Serviceport on the one clients use, no Ingress or load-balancer route to it; - let only the scraper and the kubelet reach it, with a
NetworkPolicy, or bind it to an interface only they can reach
(
addr: "10.0.0.5:9090"); - keep the catalog port for clients only. The catalog listener
answers the probes too, so a deployment that must firewall the
catalog port from the kubelet can probe
:9090instead.
Sampling
Puddle samples traces with parentbased_traceidratio:
- a request with a W3C
traceparentfollows the caller’s decision. Trino, Spark or a gateway that trace a query decide for puddle’s part of it, and a sampled caller gets puddle’s spans in its own trace; - a request without one is a root, recorded at the ratio:
OTEL_TRACES_SAMPLER_ARG, 0.1 by default.
Metrics don’t depend on sampling: every request is counted.
Advice:
- Production: keep the parent-based sampler and pick a ratio from your traffic. A busy catalog (a few hundred requests per second) needs no more than 0.01–0.05 for latency investigations; a quiet one can keep 0.1 or more. A table load is 5–10 spans, a commit 10–20.
- Tracing from the engines: if the query engines already trace,
set
OTEL_TRACES_SAMPLER_ARG=0so that puddle only records the requests the callers sampled: one decision per query, and puddle’s spans always complete the engine’s traces. - Debugging and tests:
OTEL_TRACES_SAMPLER=always_onrecords everything; a single request can also be traced by sending it with a sampledtraceparent, whatever the ratio. - To sample by outcome (keep every error and slow commit, a fraction
of the rest), use tail sampling in the collector (the
tail_samplingprocessor) withalways_onin puddle.
Metrics
/metrics on the management listener has puddle’s metrics below,
the Go runtime’s (go_goroutine_count, go_memory_used_bytes,
go_memory_allocated_bytes_total, go_memory_gc_goal_bytes,
go_processor_limit, go_config_gogc_percent, …) and the process’s
(process_cpu_seconds_total, process_resident_memory_bytes,
process_open_fds, …), and target_info with the resource.
Names are the OpenTelemetry semantic conventions’ where one exists
(http.server.request.duration is
http_server_request_duration_seconds in Prometheus), and puddle’s
own, prefixed puddle_, otherwise. Every sample also has the
otel_scope_name (the Go package that recorded it) and
otel_scope_version labels. Durations are in seconds. A counter
appears after its first increment, so an alert on one must treat
“absent” as zero.
Cardinality. Every label takes a bounded set of values: a fixed list, a configured warehouse’s name, or a configured address. Namespaces, table and view names, principals, tokens, locations and object keys never appear in a label; they are on spans, where they belong, or nowhere.
Requests
| Metric | Type | Labels | Meaning |
|---|---|---|---|
http_server_request_duration_seconds | histogram | http_route, http_request_method, http_response_status_code, puddle_warehouse, error_type, url_scheme, network_protocol_name, network_protocol_version | Catalog requests: arrival to response. http_route is the operation, as the request log line’s op (loadTable, updateTable, …; noRoute for an unknown path, redirect for a path-cleaning redirect), not a path template. puddle_warehouse is set when the request names a configured warehouse. error_type is the Iceberg error type of a 4xx or 5xx (NoSuchTableException, CommitFailedException, …) |
http_server_request_body_size_bytes, http_server_response_body_size_bytes | histogram | the same | request and response body sizes |
puddle_access_delegation_requests_total | counter | operation (loadTable, createTable, registerTable), warehouse, value | table loads by their X-Iceberg-Access-Delegation header: vended-credentials, remote-signing, both comma-joined, none (no header) or other |
Authentication and authorization
| Metric | Type | Labels | Meaning |
|---|---|---|---|
puddle_authn_attempts_total | counter | validator (static, jwt, other, or none without a credential), outcome (success, failure, no_credential) | authentication attempts |
puddle_authn_jwks_fetches_total | counter | issuer (a configured issuer), trigger (initial, scheduled, unknown_key), outcome (success, failure) | fetches of a JWT issuer’s keys |
puddle_authz_decisions_total | counter | action (table.load, table.commit, namespace.create, …), decision (allow, deny, error), probe (true for the check that picks read-only or read-write vended credentials) | authorization decisions; error is a check that failed, such as OPA unreachable |
puddle_authz_duration_seconds | histogram | backend (allow-all, allow-authenticated, opa, cel), decision | how long a decision took |
Commits
| Metric | Type | Labels | Meaning |
|---|---|---|---|
puddle_commit_duration_seconds | histogram | kind (table, view, transaction), warehouse, outcome | commits, from the first read to the last step, retries included. outcome: committed (no-op commits too), conflict (lost every attempt to a concurrent commit; the client got a 409), requirement_failed (a requirement didn’t hold; 409), error (anything else: invalid updates, a missing table, a commit whose state is unknown) |
puddle_commit_conflicts_total | counter | kind, warehouse | pointer swaps a concurrent commit won; a commit that retries counts once per lost attempt |
puddle_commit_requirement_failures_total | counter | kind, warehouse | commits refused because a requirement failed (the client’s base was stale) |
puddle_commit_uncertain_total | counter | kind, warehouse, resolution | commits whose publish failed without proof it didn’t apply. applied: the re-read showed it landed, and the client got success. unknown: puddle couldn’t tell, and the client got CommitStateUnknownException |
puddle_purge_files_total | counter | warehouse, outcome (deleted, failed, skipped: outside the table’s location) | files handled by dropTable with purge |
Metastore and storage
| Metric | Type | Labels | Meaning |
|---|---|---|---|
db_client_operation_duration_seconds | histogram | db_system_name (memory, sqlite, postgresql), db_operation_name (the metastore operation: LoadTableEntry, UpdateTable, CreateTable, …), puddle_warehouse, error_type | metastore operations. error_type is CommitFailedException for a lost pointer swap, NoSuchTableException, AlreadyExistsException, …, or canceled, timeout, _OTHER |
db_client_connection_count | gauge | db_client_connection_pool_name (the schema), db_client_connection_state (idle, used) | Postgres pool connections |
db_client_connection_max | gauge | db_client_connection_pool_name | the pool’s maximum |
puddle_db_client_connection_waits_total | counter | db_client_connection_pool_name | times a query waited for a free connection |
puddle_db_client_connection_wait_duration_seconds_total | counter | db_client_connection_pool_name | total time spent waiting for one |
puddle_vended_credentials_issued_total | counter | warehouse, mode (read, read-write) | storage credentials vended. warehouse is the one whose storage vended (for a table registered from an external warehouse, that one) |
http_client_request_duration_seconds | histogram | http_request_method, http_response_status_code, server_address, server_port, http_route, puddle_warehouse, … | calls to OPA and to JWT issuers. server_address/server_port come from the config; http_route and puddle_warehouse are those of the catalog request that made the call |
The pool metrics exist for the Postgres metastore only. Storage (S3, STS) has spans but no metrics of its own: its latency shows in the commit and request durations, and per call in the traces.
Traces
Each catalog request is a server span named <METHOD> <operation>
(GET loadTable, POST updateTable), with http.route (the
operation), puddle.warehouse, puddle.namespace (the levels),
puddle.table or puddle.view (from the path, or from the body for a
create, register or rename; puddle.rename.destination for a rename,
puddle.tables for a transaction), puddle.request.id,
puddle.access_delegation when sent, url.path and the response
status. A 5xx marks it as an error; a 4xx sets only error.type.
Under it:
| Span | What it covers |
|---|---|
authenticate | with authn: configured, the token check (puddle.authn.validator, .outcome); under it jwks fetch when an unknown key forces a refresh |
authorize | one per authorization check (puddle.authz.action, .decision, .backend, .probe); with OPA, the client call HTTP POST under it, and OPA’s own spans under that when OPA exports to the same collector |
metastore <Operation> | a metastore operation (metastore LoadTableEntry, metastore UpdateTable, …), with db.system.name; on Postgres, a span per statement under it (BEGIN, SELECT, UPDATE, COMMIT, with db.query.text) |
S3.<Operation>, STS.AssumeRole | calls to the object store and STS, with aws.s3.bucket, cloud.region, http.response.status_code, aws.request_id |
vend credentials | vending for a table load or create (puddle.vending.mode, .outcome), with STS.AssumeRole under it |
commit.table, commit.view, commit.transaction | a commit (puddle.commit.kind, .outcome), with a step span per phase, repeated per attempt on a retry |
table.create, table.register, table.rename, table.drop, table.purge, view.* | the other writes, with the steps that apply; purge.files is the background file deletion of a purge, which can outlive the request |
A table load:
GET loadTable puddle.table=orders, puddle.access_delegation=vended-credentials
├── authenticate
├── authorize table.load
├── metastore LoadTableEntry
│ └── SELECT (Postgres)
├── S3.GetObject the metadata file
├── authorize table.commit, probe: read-only or read-write credentials?
└── vend credentials read-write, issued
└── STS.AssumeRoleA commit (POST updateTable, from an engine’s append):
POST updateTable
├── authenticate
├── authorize table.commit
└── commit.table outcome=committed
├── metadata.read the current metadata
│ ├── metastore LoadTableEntry
│ │ └── SELECT
│ └── S3.GetObject
├── commit.apply requirements and updates (puddle.commit.requirements, .updates)
├── metadata.write the new metadata.json
│ └── S3.PutObject
├── pointer.swap the compare-and-swap in the metastore
│ └── metastore UpdateTable
│ ├── BEGIN
│ ├── UPDATE
│ └── COMMIT
└── sidecar.write the recovery sidecar
└── S3.PutObjectOn the commit span, events mark what went wrong on the way:
commit.requirement_failed (a requirement didn’t hold),
commit.conflict (a concurrent commit won the swap), commit.retry
(the next attempt) and commit.uncertain (the publish failed without
proof, and how it was resolved).
Never recorded, on spans or anywhere else in telemetry: tokens and
the Authorization header, the principal, storage keys and vended
credentials, OTLP headers, object keys (S3 spans carry the bucket
only), and query arguments (Postgres spans carry the statement text
with $1 placeholders, never the values). Namespace, table and view
names are on spans, never on metrics.
Kubernetes
Probes, on the management port (or http for the catalog’s; both
answer):
livenessProbe:
httpGet: { path: /healthz, port: metrics }
periodSeconds: 10
readinessProbe:
httpGet: { path: /readyz, port: metrics }
periodSeconds: 5
failureThreshold: 3A scrape with the Prometheus Operator:
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: puddle
spec:
selector:
matchLabels: { app.kubernetes.io/name: puddle }
podMetricsEndpoints:
- port: metrics
path: /metrics
interval: 30sOr, where Prometheus discovers pods by annotation:
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "9090"
prometheus.io/path: /metricsAnd a NetworkPolicy that lets only the monitoring namespace reach the metrics port (most network plugins let the kubelet’s probes through regardless; check yours):
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: puddle-metrics
spec:
podSelector:
matchLabels: { app.kubernetes.io/name: puddle }
policyTypes: [Ingress]
ingress:
- ports: [{ port: 8089 }] # the catalog, from wherever clients are
- ports: [{ port: 9090 }]
from:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: monitoring }Suggested alerts
Starting points, in Prometheus rule syntax; tune the thresholds to your traffic.
groups:
- name: puddle
rules:
# A commit whose outcome the client can't know: it must check
# whether the commit landed before retrying. Rare; always look.
- alert: PuddleUncertainCommit
expr: sum by (warehouse) (increase(puddle_commit_uncertain_total{resolution="unknown"}[15m])) > 0
labels: { severity: page }
# Clients giving up on commits after losing every retry to
# concurrent writers: jobs contending for the same tables.
- alert: PuddleCommitConflicts
expr: |
sum by (warehouse) (rate(puddle_commit_duration_seconds_count{outcome="conflict"}[15m]))
/ sum by (warehouse) (rate(puddle_commit_duration_seconds_count[15m])) > 0.05
for: 15m
labels: { severity: warn }
# Files a purge couldn't delete: left in the warehouse.
- alert: PuddlePurgeFailures
expr: sum by (warehouse) (increase(puddle_purge_files_total{outcome="failed"}[1h])) > 0
labels: { severity: warn }
# Authorization checks that errored (OPA down or slow): requests
# are being refused with 5xx.
- alert: PuddleAuthzErrors
expr: sum(rate(puddle_authz_decisions_total{decision="error"}[5m])) > 0
for: 5m
labels: { severity: page }
# Requests waiting for a Postgres connection: the pool is too
# small for the load, or the database is slow.
- alert: PuddlePoolWaits
expr: rate(puddle_db_client_connection_wait_duration_seconds_total[5m]) > 0.1
for: 10m
labels: { severity: warn }
# 5xx answers (each also logged as `request failed`).
- alert: PuddleServerErrors
expr: |
sum by (http_route) (rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) > 0
for: 10m
labels: { severity: warn }
# A JWT issuer's keys can't be fetched: new keys won't be accepted.
- alert: PuddleJWKSFetchFailing
expr: sum by (issuer) (increase(puddle_authn_jwks_fetches_total{outcome="failure"}[30m])) > 0
labels: { severity: warn }PuddlePoolWaits fires when requests spent more than 0.1 s per second
waiting for connections; also watch
db_client_connection_count{db_client_connection_state="used"}
against db_client_connection_max. Without metrics, the logs give the
same signals: request failed lines, status=503 on request lines
(requests running into their deadline), purge: stopped before finishing and purge: abandoned at shutdown, readiness check failed, and a rise in authz decision with decision=deny or in
authn rejected.
Where did a slow commit’s time go?
Say puddle_commit_duration_seconds shows p95 commits on warehouse
lake taking seconds rather than tens of milliseconds.
Which commits. Split the histogram by
outcomeandkind:histogram_quantile(0.95, sum by (le, outcome, kind) (rate(puddle_commit_duration_seconds_bucket{warehouse="lake"}[10m])))If only
conflictis slow, time goes to retries: writers contend for the same tables (puddle_commit_conflicts_totalrises with it), and the fix is on the jobs’ side. Ifcommittedis slow too, read on.The metastore or the storage. Compare
db_client_operation_duration_secondsforUpdateTableandLoadTableEntrywith the commit duration. On Postgres, check the pool: risingpuddle_db_client_connection_waits_total, orusedatmax, means commits queue for a connection before they run. If the metastore is fast, the time is in storage.One slow commit’s trace. Find a slow
POST updateTable(in Jaeger or Tempo: servicepuddle, operationPOST updateTable, minimum duration), or start from a client’s error: its request id is in the logs next to thetrace_id. The tree shows where the time went:POST updateTable 2.41 s └── commit.table 2.40 s outcome=committed ├── metadata.read 0.03 s │ ├── metastore LoadTableEntry 0.002 s │ └── S3.GetObject 0.03 s ├── commit.apply 0.001 s ├── metadata.write 2.21 s ← here │ └── S3.PutObject 2.21 s http.response.status_code=200 ├── pointer.swap 0.004 s │ └── metastore UpdateTable 0.004 s │ ├── BEGIN / UPDATE / COMMIT └── sidecar.write 0.15 s └── S3.PutObject 0.15 sHere the new metadata file’s upload is slow: the object store (or the network to it) is the cause, and the S3 span’s
aws.request_idis what the storage provider’s support will ask for. A largemetadata.writewith a fastS3.PutObjectwould instead mean a big metadata file: a table with many snapshots; expire them. Repeatedmetadata.read…pointer.swapgroups withcommit.conflictandcommit.retryevents are retries; a slowpointer.swapwith fast statements under it, or a slowCOMMIT, points at the database.Across requests. The same questions for loads: a slow
GET loadTableis usually itsS3.GetObject(a large metadata file) orvend credentials>STS.AssumeRole(STS latency, whichhttp.response.status_codeanderror.typeon the span explain when it fails).
Probes
/healthz (the process is up) and /readyz (the metastore answers
within 2s) are for the orchestrator, and without metrics they are also
the simplest thing to monitor from outside. They answer on the
management listener too. /readyz doesn’t check the warehouse’s
storage: storage failures show up as 5xx answers, request failed
lines and failing S3 spans. See
Probes.