Skip to content

Observability

Logs

Structured, to stdout, one line per request, configured by the logging section:

logging:
  level: info    # trace | debug | info | warn | error
  format: json   # json | text

RUST_LOG overrides level when set. That is deliberate: it is what an operator reaches for when they need more detail from a process they cannot reconfigure and restart. An empty or whitespace-only RUST_LOG is ignored rather than treated as an empty filter, because an exported-but-empty variable is far more often an accident than an instruction.

The request line

Every request logs once, on completion, with the same key set on every branch:

Field Meaning
request_id From the client's X-Request-ID, or generated
proxy Which proxy handled it, empty if none resolved
mock Which mock served it; absent when none did
method, path, status The request and its outcome
duration_ms Time to the response headers
upstream_contacted Whether an upstream was reached at all
upstream_status, upstream_duration_ms Present only when it was
loss_injected Whether the loss roll dropped this request
latency_injected_ms How long the request was actually made to wait

upstream_contacted is a boolean rather than a null status because a null still invites a consumer to plot it, where a boolean says what happened. A dropped request, an unresolved one and a mocked one all report false.

latency_injected_ms is the wait taken, not the delay drawn. An injected latency is a target for the whole response, so an upstream that already spent longer than the target leaves nothing to wait for and this reads 0 even though the roll fired. doppel_latency_injected_total counts the roll, so the two disagree in exactly that case -- deliberately, since "how often latency was in play" and "how much of it this request felt" are different questions. See Injecting faults.

duration_ms and upstream_duration_ms both stop at the response headers, not at the end of the body. A large download is not reflected in either.

Paths are logged in full. The prohibition on path labels applies to metrics, where cardinality is expensive; logs are not aggregated by label.

Secrets

Admin token values never reach the logs, at any level including trace, and there are tests that assert it. A database URL and a Sentry DSN are masked wherever they can surface -- a Sentry DSN carries the key that authorises sending events, in the same position a URL carries a password.

Metrics

GET /metrics on the admin listener, in the Prometheus text format, unauthenticated because a scraper has nowhere to put a token.

The one endpoint outside /api/. metrics_path defaults to /metrics in Prometheus and in every agent and annotation that scrapes one, so the path is settled by something larger than this project -- and a scrape of /metrics while the exposition was under /api/v1/ was answered by the dashboard with 200 text/html, which reads as a parse failure or as no metrics at all.

Traffic

Metric Type Labels
doppel_proxy_request_duration_seconds histogram proxy, method, status, replace, loss, upstream_error
doppel_upstream_request_duration_seconds histogram proxy, method, status
doppel_admin_request_duration_seconds histogram route, method, status
doppel_loss_total counter proxy
doppel_latency_injected_total counter proxy
doppel_mock_hits_total counter proxy, mock

replace, loss and upstream_error are 1 or 0, and independent: a mock can answer a request its own loss roll then drops, and upstream_error covers a transport failure, a timeout, and a relayed status of 500 or above.

route on the admin histogram is the route template -- /api/v1/proxies/{name}, never /api/v1/proxies/alpha -- so a hundred proxies are one series and a query string is none. A request that matched no route is recorded with an empty route.

Buckets are explicit rather than the exporter's default summary, because a quantile cannot be aggregated across replicas. Two ladders: 5ms to 60s for proxied traffic, because latency injection is asked to be slow on purpose, and 5ms to 5s for the admin API, where nothing has any business taking longer.

State

These exist from startup, before anything has happened, because a panel and an alert both read a never-recorded metric as "no data" -- which is indistinguishable from a process nobody is scraping.

Metric Type Labels Meaning
doppel_build_info gauge version Always 1. The version is a label because it is not a number
doppel_dashboard_info gauge enabled Always 1. Separate from the build: one describes the artifact, the other what this deployment turned on
doppel_proxy_last_error_timestamp_seconds gauge code When the proxy listener last answered with that error. Starts as code="", value 0
doppel_proxy_mocks gauge proxy Mocks per proxy in the configuration now in effect, republished on every reload and every write through the API. A proxy that leaves the configuration goes to 0 rather than keeping its last count

"Is it still failing" is time() - doppel_proxy_last_error_timestamp_seconds, which is why the metric is a timestamp and not a counter: a counter answers "how many" and needs a rate window to say anything about now.

Every request through the proxy listener appears in the proxy histogram, including one that was dropped by loss injection or that resolved to no proxy at all; otherwise a rise in failures would read as a fall in traffic. A request answered by a mock produces no upstream observation, because no upstream was contacted.

A request that resolved to no proxy is recorded with an empty proxy label. Rule V35 refuses an empty proxy name, so it cannot collide with a real one.

No path labels

Not by convention but by construction: no recording function in doppel_core::metrics accepts a path, so there is nothing to forget at a call site.

method is bounded the same way. It arrives from the wire, so an unbounded label there is a cardinality explosion any client can trigger by inventing methods. Anything outside the recognised set is recorded as OTHER.

Sentry

Optional, behind the sentry cargo feature and off in a default build:

cargo build --release --features sentry

Neither the released binaries nor the published image carry it: a default build should not ship a TLS stack and an HTTP client it never uses. Reporting to Sentry means building your own, which is the trade this flag is.

sentry:
  dsn: "https://key@sentry.example.com/1"

An absent section or an empty DSN disables it and is not an error. A malformed DSN fails startup rather than producing a client that silently drops everything -- with the key masked in the message.

The DSN from the environment

DOPPEL_SENTRY_DSN="https://key@sentry.example.com/1"

A DSN carries the key that authorises sending events, which makes it the one Sentry setting that is a credential -- and a deployment that provisions credentials through the environment should not have to write this one into a document the admin API returns and the store keeps.

The variable wins over sentry.dsn, the same way DOPPEL_ADMIN_TOKENS wins over admin.tokens: it is how a deployment overrides a document it may not be able to edit. Startup says which source it used, so the answer is one line away rather than a guess:

{"level":"INFO","fields":{"message":"sentry reporting enabled",
 "dsn":"https://<redacted>@sentry.example.com/1","source":"DOPPEL_SENTRY_DSN"}}

An empty variable is not a way to turn reporting off. It counts as unset and leaves sentry.dsn in force, deliberately: DOPPEL_SENTRY_DSN=${SENTRY_DSN} with nothing behind SENTRY_DSN is a compose file that means nothing by it, and silently disabling error reporting is the worse reading. To turn it off, write dsn: "" or remove the section.

Doppel reads its own name, not the conventional SENTRY_DSN. A variable that is in the environment for the service beside this one should not make this one start reporting to it.

A build without the feature that is given a DSN warns at startup and carries on. It does not pretend to report, and it does not refuse to run: reporting is optional by design, so turning a missing integration into an outage would be worse than the gap.