Telemetry¶
Telemetry is the OpenTelemetry environment, exported only to a named collector, without personal data.
Configuration¶
The platform sets these variables on the pods; the configuration file has no telemetry key.
| Variable | Effect |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
Names a collector for metrics and traces. With neither this nor a signal's own variable set, nothing is exported. |
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
The same, for one signal. |
OTEL_EXPORTER_OTLP_HEADERS, OTEL_METRIC_EXPORT_INTERVAL, OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES |
The SDK's own. With OTEL_SERVICE_NAME unset the process names itself sluis, and the controllers' series carry that name too: select service_name sluis, not a controller name. |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG |
The sampler, in the SDK's standard names: always_on, always_off, traceidratio, parentbased_always_on, parentbased_always_off, parentbased_traceidratio (the argument is the rate, 0 to 1). Unset, it is parentbased_always_on: a caller's sampled decision is followed and every root trace is kept. When the variable is set, the environment wins and the built-in default (defaultSampler in internal/telemetry/telemetry.go) is not applied; there is no configuration-file key. To keep a tenth of root traces while following the caller, set parentbased_traceidratio with 0.1. All SDK spans are kept: the exporter's allowlist filters attributes, never spans, so a rate is the only way to reduce volume (about eleven spans per AWS call). |
| Log lines and sampling | A log line written inside a request carries trace_id and span_id whether or not the trace is sampled, and trace_flags (01 sampled, 00 not). A Grafana derived field that links to the trace should match trace_flags 01, so it links only traces that were kept. |
Wiring it with the chart¶
The chart sets them from telemetry.otlp; an empty endpoint renders nothing (install telemetry).
| Variable | From |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
telemetry.otlp.endpoint, an http(s) URL |
OTEL_EXPORTER_OTLP_PROTOCOL |
telemetry.otlp.protocol, default http/protobuf |
OTEL_SERVICE_NAME |
sluis |
each telemetry.otlp.extraEnv entry, sorted, last |
OTEL_* names only, never OTEL_EXPORTER_OTLP_ENDPOINT |
Traces¶
A tracer exists only when a collector is named for traces.
| Where | Span |
|---|---|
| The issuer's listener (everything on the public port, the console included) | A server span per request, named for the route (POST token, GET login_callback) and never for the path. The route is one of a fixed set (issuer.Route): discovery, jwks, authorize, authorize_callback, token (/token, which discovery names, and /oauth/token), token_mint (a token exchange for a Cloudflare credential, an audience cloudflare:<preset>: the handler renames the request once it has read the form, because a mint waits seconds on the Cloudflare module and on Cloudflare; the migration freeze still decides on token, so it admits the exchange and the frozen Cloudflare module refuses the mint with 503 maintenance), userinfo, introspect, revoke, end_session, device_authorization, login_chooser, login_start, login_callback, login_recovery, logout, signed_out, account, grants, sluis_configuration, client_secrets, sessions_rpc, connect_callback, console_assets, console_rpc, console, other. The caller's traceparent is continued. |
| The console's and the session service's Connect handlers | A server span per call (rpc.method). |
| The controllers' clients of the console | A client span per call, and the traceparent carried to the console, so a tick's trace continues into the console's own spans. |
A module call (internal/modcall) |
A client span in the caller and a server span in the callee, modcall <module>.<method> (rpc.system.name is sluis.modcall, rpc.method the module and method). The envelope carries the traceparent and tracestate, so the callee's span is the caller's child on every transport. |
| A tick | tick <kind>, with the target kind, the target and the outcome (ok or failed). The kinds are github-tick, github-links and slack-tick. |
An AWS SDK call (every client of every adapter: storage/awsotel) |
One span per call named <Service>.<Operation> (SSM.GetParameter; rpc.system is aws-api, with rpc.service and rpc.method), a child of the span in the call's context, and under it ten child spans: RetryLoop, Attempt (one per try), OperationSerializer, ResolveAuthScheme, GetIdentity, ResolveEndpoint, ComputePayloadSHA256, SignRequest, DoHTTPRequest and OperationDeserializer. The SDK's own client metrics go to the same meter provider. |
| A function invocation on Lambda | The server span of a module call (and of an API Gateway request) carries the Lambda request id as faas.invocation_id, and continues the caller's traceparent. X-Ray is not used. |
A Cloudflare API call (the Cloudflare module, internal/cloudflare/cfapi) |
A client span per method, cloudflare <Method> (rpc.system is cloudflare, rpc.method ListTokens, GetToken, CreateToken, DeleteToken or PermissionGroups); a listing carries the pages it read (sluis.cloudflare.pages) and the entries it returned (sluis.cloudflare.listed). Under it one span per attempt, cloudflare <HTTP method>, with http.response.status_code, error.type and http.request.resend_count (0 the first try), so a retry is a span of its own. Never the URL, the account, a token's id, name or value. |
| A storage port call | port <port> <operation>, with the port, the operation and the outcome. Only inside a trace that is already being recorded, so a loop that started none makes no root span per call. |
What leaves the process¶
Spans leave through the allowlist exporter telemetry.FilterExporter; only these attributes survive (telemetry.SpanAttributeAllowlist):
access_roster.target.kind access_roster.target.id access_roster.outcome
access_roster.port access_roster.operation rpc.system.name rpc.method
rpc.response.status_code error.type http.request.method
http.response.status_code http.route faas.invocation_id rpc.system
rpc.service aws.request_id http.status_code http.request.resend_count
sluis.cloudflare.pages sluis.cloudflare.listed
Events, link attributes and status text are removed. A port call's key is never an attribute. A test pins it.
Metrics¶
Prometheus names turn dots into underscores and add _total to counters: sluis.http.requests is sluis_http_requests_total. Only cluster, namespace and tier come from the resource.
The issuer¶
| Metric | Type | Labels | What it says |
|---|---|---|---|
sluis.http.requests |
counter | route, status_class |
Requests the listener answered. status_class is 2xx to 5xx. |
sluis.http.request.duration |
histogram, s |
route, status_class |
Time from a request's arrival to the handler returning. Buckets 5 ms to 60 s; the 30 s and 60 s bounds are for a Cloudflare mint (token_mint), which is synchronous and takes seconds. |
sluis.tokens.issued |
counter | client_id, grant_type |
Access tokens signed. grant_type is authorization_code, refresh_token, token_exchange, client_credentials or console_mint. |
sluis.login.failures |
counter | reason |
Sign-ins that did not complete. |
sluis.login.successes |
counter | method |
Sign-ins that completed: a directory's kind, recovery, conformance, browser_session or agent_consent (an agent connection accepted on the consent page). |
sluis.reuse_detected |
counter | kind |
A spent credential presented again: authorization_code or refresh_token. |
sluis.dead_refresh_token_hits |
counter | none | A refresh token refused from the issuer's in-process negative cache: one read as naming no live session twice, at least 60 s apart, and presented again within 5 minutes of the second, refused with no State read. A steady rate is a client looping on an ended chain; the WARN refused a refresh token that names no live session names it once per cache entry, with the client id the request named (empty for private_key_jwt) and an 8-hex token_fingerprint, at most 30 such lines a minute. |
sluis.spent_mark_ahead |
counter | none | A spent refresh token mark read that is dated more than 2 s ahead of the replica's clock. The replicas' clocks disagree, which moves the 30-second grace window. The issuer logs a WARN and keeps the grace. |
sluis.signing_keys_published |
gauge | algorithm |
Keys in the JWKS, per algorithm. Healthy is at least one. |
sluis.signing_key.active_since_timestamp |
gauge, s |
algorithm |
When the active key became active (Unix seconds). |
sluis.signing_key_transitions |
counter | event, algorithm |
Keys seen, activated, retired. |
sluis.kms_signatures |
counter | kid, result |
kms:Sign calls of a KMS signer: ok, throttled or error (signing with KMS). |
client_id is bounded by the policy: undeclared is other, none is none. reuse_detected counts codes presented twice, refresh tokens spent past the 30-second grace, unknown refresh tokens, and negative-cache refusals. A reused code revokes what it issued; a spent refresh token of a live session ends that session.
login.failures has these reason values:
| Reason | Meaning |
|---|---|
bad_state |
the callback or form state is missing, expired, forged or from another browser |
unknown_provider |
the provider is not declared |
provider_failed |
the directory's exchange failed |
directory_refused, directory_unreachable |
the directory refused or did not answer |
not_entitled |
signed in, but not in a group the application requires |
recovery_refused |
the recovery sign-in was refused |
recovery_off |
a recovery proof was presented after an operator turned recovery off from the console |
recovery_throttled |
a recovery proof was not looked at: its source had spent its refusals for the window |
unaudited |
the audit trail could not be written |
not_waiting |
the authorization request is gone |
consent_refused |
an agent connection's consent was not accepted in that browser |
subject_changed, subject_unchecked |
the address first signed in from another account at the provider, or that could not be checked |
bad_request |
the request was malformed |
Generated client secrets¶
Source: internal/clientcreds/telemetry.go.
| Metric | Type | Labels | What it says |
|---|---|---|---|
sluis.client_secret.reconcile |
counter | outcome |
Generated secrets looked after: created, adopted, existing, conflict, unsupported or failed. |
sluis.client_secret.auth |
counter | slot |
Confidential client authentications at the token endpoint, by the secret that matched: current, previous or none. |
sluis.client_secret.rotations |
counter | outcome |
Rotations a person asked for: ok, busy, not_generated, no_record or failed. |
sluis.client_secret.purges |
counter | outcome |
Purges a person asked for: ok, still_declared, no_record, busy or failed. |
sluis.client_secret.orphans |
counter | none | Stored records newly found with no generated client in the policy, each counted once. |
sluis.client_secret.admin_refused |
counter | reason |
Requests to the admin endpoint refused before they acted: unauthenticated, forbidden or wrong_audience. |
Cloudflare credentials¶
| Metric | Type | Labels | What it says |
|---|---|---|---|
sluis.cloudflare.rotation.last_timestamp |
gauge, s |
preset |
When a preset's stored credential was minted (Unix seconds). |
sluis.cloudflare.rotation.interval |
gauge, s |
preset |
The preset's configured rotation. |
sluis.cloudflare.tokens.minted |
counter | preset, variant, outcome |
Credentials minted. variant is stored or on_demand; outcome is ok, refused (a prototype or a grant said no) or failed. |
sluis.cloudflare.tokens.swept |
counter | preset |
Expired tokens deleted. |
sluis.cloudflare.prototype.refused |
counter | preset, reason |
Prototypes refused at a mint or at the check on start (prototype_active, prototype_forbidden, prototype_missing). Any is an error to look at. |
The controllers and the rails¶
| Metric | Type | Labels | What it says |
|---|---|---|---|
sluis.ticks |
counter | kind, target, outcome |
Ticks. outcome is ok (the tick ran to its report, whatever the report says about the target) or failed. |
sluis.tick.duration |
histogram, s |
kind, outcome |
How long a tick took. |
sluis.tick.last_success_timestamp |
gauge, s |
kind, target |
When a target's last ok tick ended (Unix seconds), as this process saw it. Per process: on Lambda every warm environment exports its own under the same labels, so the series mixes fresh and hours-old stamps. Do not alert on it across environments; count ok ticks on sluis.ticks. |
sluis.leases.acquired |
counter | kind |
Leases taken by this runner. |
sluis.leases.contended |
counter | kind |
Leases asked for and held by another runner. Added to by 0 at a kind's first lease, so it reads 0 before the first event. |
sluis.leases.skipped |
counter | kind |
Ticks not started because the module is under maintenance. Added to by 0 at a kind's first lease, so it reads 0 before the first event. |
sluis.leases.lost |
counter | kind |
Leases held and lost: taken over, or not renewable for a whole lifetime. The tick stopped before its next write. Added to by 0 at a kind's first lease, so it reads 0 before the first event. |
sluis.leases.held |
up-down counter | kind |
Leases this runner holds now. |
Each tick emits sluis.ticks, sluis.tick.duration and, when ok, sluis.tick.last_success_timestamp (kind: github-tick, github-links, slack-tick, outline-tick). SluisTickSeriesStale reports two hours of silence; absent(...) the rest.
absent(last_over_time(sluis_ticks_total[1d]))
Other controller series:
github_roster.passes (org, outcome) .changes .link_changes .breaker_trips .rows
.seats_free .seats_short .links .rate_limited (kind) .rate_limit_remaining (resource)
slack_roster.passes (workspace, outcome) and the same family
audit.emit.* (the audit component's, alerted there)
Adapters and secrets¶
| Metric | Type | Labels | What it says |
|---|---|---|---|
sluis.adapter.info |
gauge | concern, adapter |
The adapter in use for each concern; the value is always 1. |
sluis.secret.reads |
counter | result |
Reads of a stored secret document: hit (answered from the process) or miss (read from the backend). |
The ports¶
| Metric | Type | Labels | What it says |
|---|---|---|---|
sluis.port.operation.duration |
histogram, s |
port, operation, outcome |
One storage port call. |
| Label | Values |
|---|---|
port |
state, index, blob |
operation |
get, put, create, update, delete, delete_if_revision, peek_revision, list, add, remove, members, read, write, write_if_version, replace, read_all |
outcome |
ok, not_found, exists, conflict, unavailable (store down), canceled (caller gave up), error |
A conflict is a lease or session rotation working. Issuer and port series carry no workspace, organisation, person or group label; controller series carry target, org or workspace.
Alerts¶
Thirteen rules, rendered with renders: alerts. A threshold is alerts.rules.<rule>. A rule whose series is absent does not fire.
Source: tests/golden/sluis/alerts.yaml, the render of charts/sluis/templates/alerts.yaml with default values (13 rules). The expressions carry the default thresholds; every one is a value under alerts.rules.
| Alert | Severity | For | What it says | Default expression |
|---|---|---|---|---|
SluisNoSigningKeyPublished |
critical | 5m | The issuer's key ring holds no key to publish for this algorithm, so its JWKS is empty and no relying party can verify a token. | min by (k8s_cluster_name, namespace, algorithm) (sluis_signing_keys_published{namespace="sluis"}) < 1 |
SluisSigningKeyRotationStalled |
warning | 1h | The active signing key is older than 30240000s and has not rotated. | time() - max by (k8s_cluster_name, namespace, algorithm) (sluis_signing_key_active_since_timestamp_seconds{namespace="sluis"}) > 30240000 |
SluisIssuer5xx |
critical | 10m | More than 5% of the requests in the last 10 minutes failed on the server side. | ( sum by (k8s_cluster_name, namespace) (increase(sluis_http_requests_total{namespace="sluis",status_class="5xx"}[10m])) / sum by (k8s_cluster_name, namespace) (increase(sluis_http_requests_total{namespace="sluis"}[10m])) ) > 0.05 and sum by (k8s_cluster_name, namespace) (increase(sluis_http_requests_total{namespace="sluis",status_class="5xx"}[10m])) >= 5 |
SluisTokenEndpointSlow |
warning | 15m | Token requests are taking longer than 2s at the 99th percentile over 30m, Cloudflare mints apart. | histogram_quantile(0.99, sum by (k8s_cluster_name, namespace, le) (rate(sluis_http_request_duration_seconds_bucket{namespace="sluis",route="token"}[30m]))) > 2 and sum by (k8s_cluster_name, namespace) (increase(sluis_http_request_duration_seconds_count{namespace="sluis",route="token"}[30m])) >= 100 |
SluisTokenMintSlow |
warning | 15m | Cloudflare credential mints are taking longer than 10s at the 95th percentile over 1h. | histogram_quantile(0.95, sum by (k8s_cluster_name, namespace, le) (rate(sluis_http_request_duration_seconds_bucket{namespace="sluis",route="token_mint"}[1h]))) > 10 and sum by (k8s_cluster_name, namespace) (increase(sluis_http_request_duration_seconds_count{namespace="sluis",route="token_mint"}[1h])) >= 5 |
SluisTickFailing |
warning | 0m | The controller's tick of this target failed 3 or more times within 45m. | sum by (k8s_cluster_name, namespace, kind, target) (increase(sluis_ticks_total{namespace="sluis",outcome="failed"}[45m])) >= 3 |
SluisTickStale |
critical | 10m | This target is still reporting ticks but none of them succeeded in the last 3600s. | max by (k8s_cluster_name, namespace, kind, target) (last_over_time(sluis_ticks_total{namespace="sluis"}[15m])) unless sum by (k8s_cluster_name, namespace, kind, target) (increase(sluis_ticks_total{namespace="sluis",outcome="ok"}[3600s])) > 0 |
SluisTickSeriesStale |
warning | 5m | No sample of this target's tick counter has arrived for 15m, though one did within the last two hours. | max by (k8s_cluster_name, namespace, kind, target) (last_over_time(sluis_ticks_total{namespace="sluis"}[2h])) unless max by (k8s_cluster_name, namespace, kind, target) (last_over_time(sluis_ticks_total{namespace="sluis"}[15m])) |
SluisLeaseLost |
warning | 0m | Controllers lost their lease on a target 3 or more times within 1h. | sum by (k8s_cluster_name, namespace, kind) (increase(sluis_leases_lost_total{namespace="sluis"}[1h])) >= 3 |
SluisCloudflareRotationStale |
critical | 5m | The stored credential of this preset is older than 2 times its rotation. | time() - max by (k8s_cluster_name, namespace, preset) (last_over_time(sluis_cloudflare_rotation_last_timestamp_seconds{namespace="sluis"}[1d])) > 2 * max by (k8s_cluster_name, namespace, preset) (last_over_time(sluis_cloudflare_rotation_interval_seconds{namespace="sluis"}[1d])) |
SluisGitHubRateLimitLow |
warning | 30m | The GitHub budget for this resource has been under 100 requests for 30m. | min by (k8s_cluster_name, namespace, resource) (github_roster_rate_limit_remaining{namespace="sluis"}) < 100 |
SluisSeatsShort |
warning | 30m | The controller could not invite everyone the policy admits to this organisation because it has no free seats. | max by (k8s_cluster_name, namespace, org) (github_roster_seats_short{namespace="sluis"}) > 0 |
SluisPortErrors |
critical | 10m | More than 5% of the calls to this storage port failed in the last 5 minutes. | ( sum by (k8s_cluster_name, namespace, port) (increase(sluis_port_operation_duration_seconds_count{namespace="sluis",outcome=~"unavailable\|error"}[5m])) / sum by (k8s_cluster_name, namespace, port) (increase(sluis_port_operation_duration_seconds_count{namespace="sluis"}[5m])) ) > 0.05 and sum by (k8s_cluster_name, namespace, port) (increase(sluis_port_operation_duration_seconds_count{namespace="sluis",outcome=~"unavailable\|error"}[5m])) >= 5 |
Lambda¶
| Signal | Where it is read |
|---|---|
| Cold starts, init duration, init timeouts and errors | No metric carries them. The platform's INIT_REPORT events (event.name platform.initReport; faas.name, status, durationMs) reach the log store, and the sluis-lambda dashboard reads them there. status timeout is the 10 second init limit. Lambda runs a failed init again inside the first invocation and reports it again (phase invoke). No rule alerts on it: the chart's rules are over metrics |
client_call_errors_total, client_call_duration_seconds_count |
The AWS SDK's client metrics (rpc_service, rpc_method, exception_type), read by the overview's "AWS SDK calls" row for jobs named sluis. ConditionalCheckFailedException, NotFound, PreconditionFailed and ParameterNotFound are ordinary conditional outcomes, left out of the failure share |
Runbook¶
| Alert | Cause and check |
|---|---|
SluisNoSigningKeyPublished |
The key ring is empty, so the JWKS is empty. Check the Secret at config.signingKey.file (cert-manager's Certificate or signingKey.existingSecret) and the issuer log ("the active signing key changed", "a signing key was seen"). A rollout shows zero for seconds, not five minutes. |
SluisSigningKeyRotationStalled |
The active key was not replaced. cert-manager renews signingKey.certificate.renewBefore early (720h before 8760h), so a key is at most about 335 days old and the rule fires at 350. Check kubectl describe certificate and config.signingKey.pollInterval. After changing duration or renewBefore, set maxAgeSeconds to their difference plus two weeks. For hand rotation, turn the rule off or set your cadence. |
SluisIssuer5xx |
Server errors. The dashboard's 5xx panel names the route. If SluisPortErrors also fires, the store is the cause. Otherwise check directory_unreachable and policy loading. |
SluisTokenEndpointSlow |
Slow p99 over 30 minutes with at least 100 token requests, Cloudflare mints apart. A token costs one store write: check the port latency panel (DynamoDB, or the API server with the legacy store), then the policy size. |
SluisTokenMintSlow |
Cloudflare mints' p95 over 10 s in an hour, with at least five mints. A mint is synchronous: the token endpoint calls the Cloudflare module, which reads the account's tokens and creates one at Cloudflare, so seconds are normal for now. Check the Cloudflare module's traces (one span per Cloudflare API call, with its status and attempt) and its function's duration and throttles. |
SluisTickFailing |
A target's ticks failed three times in 45 minutes. The console's controller page and the log ("a pass over an organisation failed") give the error: uninstalled App, revoked credential or GitHub down. |
SluisTickStale |
A target still counting ticks has had no ok tick for an hour: the lease is held, or the loop hangs or fails. Check the log. |
SluisTickSeriesStale |
A target's tick counter stopped: a target renamed or removed (clears in two hours), or a gone controller. |
SluisLeaseLost |
One loss is normal. Repeated losses mean two runners on one target (more than one replica without a shared State, which the chart refuses; high availability) or an unreachable State: see SluisPortErrors. |
SluisCloudflareRotationStale |
A preset's credential is older than twice its rotation. The log line "a Cloudflare token was not minted" gives the reason: prototype_active, prototype_forbidden, prototype_missing, minter_missing, store_error or cloudflare_error. On Lambda check that the {"kind":"cloudflare"} schedule invokes the function. |
SluisGitHubRateLimitLow |
Budget under 100 for half an hour. github_roster_rate_limited_total shows waits. Lengthen the controller's interval or find what else spends the App's budget. |
SluisSeatsShort |
Fewer free seats than invitations. Buy a seat or remove a member; the next pass invites. |
SluisPortErrors |
Over 5% of storage calls failed. unavailable is the store down: check its health and NetworkPolicy. error is anything else: the log line names the call. Conflicts and missing keys do not count. |