Operations manual

Observability Specification

Status: v1.47 draft (to be elaborated before production hardening — the rules marked normative below are already binding) Owner: datapipelines.co core Depends on: all other specs Last updated: 2026-11-01


1. Purpose

Observability for datapipelines.co covers logs, metrics, traces, and health endpoints — the signals an operator uses to detect, diagnose, and resolve issues in production.

This spec is lighter than the others because observability is cross-cutting and mostly follows industry-standard patterns. It documents the specific decisions made for v1 and the conventions modules follow. Dashboards, alert rules, SLOs and log-retention policy are still to be elaborated before production hardening — this remains a draft in that sense.

Four parts of it are normative today and binding on implementation:

Normative area Where
Correlation-id propagation (DP-Correlation-Id, SSE payloads, MCP _meta) §3.3
Metric names, tag sets and cardinality limits §4.1, §4.3
Health payload and paths (owned by REST API §11.1) §6
Log redaction mechanism and the sensitive-key list §9.2

2. Design Principles

  1. Structured logs only. Every log entry is JSON with stable field names. No println, no ad-hoc string formatting in logs.
  2. Correlation IDs everywhere. Every request carries a correlation ID (DP-Correlation-Id, REST API §3.4, registered in the DP- header registry §3.6) through every log line, trace span, SSE event payload and MCP tool result. Pipeline executions extend this to per-node correlation. Correlation ID is never a metric tag (§4.3).
  3. Metrics via Micrometer. Spring Boot's default micrometer integration; no custom metrics framework.
  4. Tracing via OpenTelemetry is a design target. Vendor-neutral instrumentation and export remain deferred (§5); the current build does not enable them.
  5. Health endpoints for orchestrators. Root-level /health and /ready for k8s liveness/readiness probes. The payload contract is owned by REST API §11; §6 here restates it only as far as needed for operators.
  6. No sensitive data in logs — enforced at the encoder, not by convention. Passwords, API keys, JDBC URLs with credentials, encryption keys, query result values are scrubbed by the logging pipeline itself, so no logger call site can bypass the rule (§9.2).

3. Logging

3.1 Format

JSON via Spring Boot's structured logging (logging.structured.format's logstash format) with the house redacting customizer — no new dependency (#337). Every log entry:

{
  "@timestamp": "2026-09-30T14:30:00.123456789-04:00",
  "level": "INFO",
  "logger": "co.datapipelines.dag.PipelineExecutor",
  "thread": "dag-executor-3",
  "message": "Node completed",
  "correlation_id": "uuid",
  "execution_id": "uuid",
  "pipeline_id": "uuid",
  "node_id": "fetch_orders",
  "duration_ms": 1266,
  "rows_out": 12453
}

Standard fields: @timestamp, level, logger, thread, message — the logstash format's logger_name/thread_name are renamed to logger/thread by the binding. MDC entries and SLF4J key-value pairs are flattened at the root (so correlation_id and execution_id sit beside the standard fields, not nested), which is what makes the example's context fields the same shape the code writes. The format also carries @version, level_value, tags (marker names) and stack_trace (when a throwable is logged); they are additional, never a replacement for the standard fields.

The switch is datapipelines.observability.logging.format (configuration.md §3.15): json selects this line, console selects the human-readable development format (§3.5). It is resolved before logging initialises, and the value set is closed — anything else refuses startup. Under json the binding also switches Spring's banner off (spring.main.banner-mode=off, bound after any operator setting): the banner prints to stdout before logging exists, and stdout under json is a stream of records — every non-blank line of a normal startup is one JSON object (#337-b, pinned by a normal-boot test). Under console the banner stays.

3.2 Levels

Level When used
ERROR Failures requiring operator attention (H2 cleanup failed, datasource pool exhausted, uncaught exception).
WARN Degraded operation (rate-limited request, retry attempted, type-mapping fallback to STRING). The rate limiter's own unavailability is logged once per outage per instance — on the healthy → unavailable transition, with an INFO when Redis answers again — never once per refused request: at the full request rate that line is a second outage, in the log pipeline. Requests refused meanwhile answer 429 rate_limit.unavailable (REST API §12.3).
INFO Noteworthy events (pipeline execution started/completed, datasource registered).
DEBUG Diagnostic detail (template rendered SQL, H2 table created). Disabled in production.
TRACE Very fine-grained (per-row processing). Disabled in production.

3.3 Correlation ID propagation

  • Set on inbound request from the DP-Correlation-Id header, or generated if absent; echoed back on the response (REST API §3.4).
  • Stored in MDC at request start, cleared at request end.
  • Propagated through async work: a coroutine ThreadContextElement built from the REQUEST's ids carries correlation_id and execution_id across every suspension of the execution's coroutines (installed once in PipelineExecutor.execute, re-added explicitly at the per-node deadline scope, whose fresh CoroutineScope inherits no elements), and every pool whose threads run inside node work captures the submitter's MDC at submission and restores the thread's state after — the script-evaluation, selector-statement (and its abandon thread), template-render, template-parse and cancel-re-issue threads (#337).
  • Included in every log line in the request's call tree — as the correlation_id / execution_id members under json, and as correlation_id=<id> / execution_id=<id> fields before the message under console (- when the event has no such context, never an id left over from an earlier event; the console names exactly these two MDC keys and never dumps the MDC) (#337-b).
  • Pipeline executions add execution_id and propagate it to every node-execution log.

Deliberately NOT propagated, by design (#337) — a line there must not wear a caller's id, because the unit of work outlives or spans callers: the persistence batching writer's threads and the shutdown drain (one batch spans many callers), the scheduled jobs' scheduler (dp-scheduled) and the SSE log/heartbeat schedulers (no request), and the mail worker (a claim row is picked up long after the request that queued it returned).

Normative — propagation past the HTTP boundary. A correlation ID that stops at the response header is useless for the two asynchronous surfaces this system exposes:

Surface Rule
SSE Every event payload emitted on an execution stream carries correlation_id — the correlation ID of the request that started the execution — for all event types in REST API §6.4, including execution_aborted. A client that only ever sees the stream can still quote an ID to an operator.
MCP Every tool result, success or error, carries correlation_id in its _meta — already normative in MCP Server §6.3; not restated here.
Traces Same value appears as the correlation_id span attribute (§5.2), which is what joins logs to traces.
Composition A child execution spawned by a PIPELINE node inherits its parent's correlation ID — it is not regenerated per execution. One SELECT … WHERE correlation_id = ? over pipeline_executions therefore returns the whole family, and every descendant's logs and SSE payloads quote the same ID as the request that started it (Pipeline Contract §8.5). root_execution_id joins one family; correlation ID joins everything one request caused, which is the wider question an operator actually starts from.

3.4 What's logged per module

Module Key log events
auth login success/failure, key issuance/revocation (via audit log, not general log); the §3.4B mail events
datasources datasource registered/updated/deleted, pool built/retired/reconciled, connection acquisition failures, and the §3.4A pool hard-close WARN
staging H2 instance created/closed, staging operation success (table name + row count), memory-limit warnings
dag execution started/completed/failed, node started/completed/failed, cancellation; the crash sweep's and the event retention's ticks — a failed tick names the store failure by error (its class) and sql_state, never its message (§3.4G's rule, #321)
templates template registered (id + version), render failures
mcp-server tool calls (tool name + caller), transport errors
web request log (method, path, status, duration), CORS preflight, SSE connections opened/closed

The tables below catalogue thirteen namespaces: audit, dashboard, datasource, endpoint, execution, pipeline, shutdown, parameter, lake, mail, persistence, scheduler and retention. All thirteen currently catalogued namespaces are enforced by ObservabilityEventCatalogParityTest (#439, #443): the event=<namespace>.<name> literals under modules/<module>/src/main/kotlin and the backticked names in these tables' event column must agree in both directions. Per-namespace non-vacuity floors prevent an empty extraction from passing. This is a source-text check (comments count), not an AST or runtime inventory; uncatalogued code namespaces remain outside it. Computed event identities in files logging an enforced namespace are refused. The two exact retained audit_log write failed formats in AuditLogger.kt (§3.4G) carry an audit-row identity in event={} rather than a structured-log identity; each exception is pinned to that file and required exactly once. audit_event={} is a separate field.

3.4A The pool-retirement events (094)

A datasource that is edited or deleted has its pool RETIRED, not closed on the spot: it leaves the live map immediately, stops handing out connections, and closes once the statements already running on it finish (Datasources §5.2). Four pool events name that lifecycle; pool_hard_closed is its non-routine outcome. This section also catalogues bootstrap and invalidation failures under datasource.*.

Level event= When Fields
INFO datasource.pool_invalidated_remotely A peer instance saved or deleted the datasource and this instance retired its pool on the message datasource, origin
INFO datasource.pool_reconciled The (re)subscription comparison found a pool built from a row that has since changed or gone, and retired it datasource, reason
WARN datasource.pool_hard_closed The retirement ceiling fired while connections were still out — the pool was closed anyway and those statements lost their connection datasource, active_connections, ceiling_seconds
INFO datasource.pool_reconcile_on_subscribe The invalidation channel was (re)subscribed and the reconcile ran channel, retired
WARN datasource.pool_invalidation_publish_failed A committed datasource change could not be published to Redis; peers may keep stale pools. One line per failed publish, with no retry queue datasource, message (exception message), reason (static explanation)
WARN datasource.pool_invalidation_malformed One pub/sub message could not be parsed and was dropped; the subscriber remains alive. One line per malformed message reason=dropped, message (first 200 characters of the parser exception's message)
WARN datasource.pool_reconcile_failed The live-pool comparison at boot/reconnect subscription threw; retry is at the next (re)subscription, not a periodic tick message (exception message), reason (static explanation)
INFO datasource.bootstrap_start Before applying each configured bootstrap datasource file, after resolving the bootstrap actor; no configured files means no line file, actor_user_id, actor_provider
INFO datasource.bootstrap_registered An absent datasource was validated and saved; one line per newly registered entry during boot name, dialect, readonly, scope=global
INFO datasource.bootstrap_skipped One bootstrap entry already exists (soft-deleted rows count), or another instance won the insert race; its row is kept. Credential reconciliation and any eligible lake seed still follow the already-present case name, reason (already_present/concurrent_registration), message (static explanation)
WARN datasource.bootstrap_credential_resynced For one existing entry, the stored credential failed authentication and the file credential worked; only the credential was replaced. One line per resync, not for unchanged working credentials name, env_key (environment-variable name or <literal>, never the credential), message (static explanation)
ERROR datasource.bootstrap_credential_broken Both stored and file credentials failed authentication for one entry; the row stays unchanged and boot continues. Fix the named environment variable/database role or edit the datasource name, env_key (variable name or <literal>), message (static explanation)
ERROR datasource.bootstrap_credential_unreadable The stored credential could not be decrypted; it is not replaced, the row stays unchanged and boot continues. Check the deployment encryption key name, message (static explanation; no key value)
INFO datasource.bootstrap_lake_seed_skipped A bootstrap entry has a lake import but its datasource is soft-deleted; one line per skipped seed during boot name, reason=soft_deleted, message (static explanation)
INFO datasource.bootstrap_lake_imported The lake table import returned, for one eligible bootstrap entry; repeated boots can find all tables already registered name, registered, already_registered (counts)
INFO datasource.bootstrap_lake_seeded An eligible entry's lake seeder returned, even if the idempotent import added nothing; one line per completed seed during boot name
INFO datasource.bootstrap_complete The registrar finished one file without a propagated failure; counts describe that invocation, not the deployment total. Broken credentials can be counted while boot continues file, registered, skipped, resynced, broken, lake_seeded (counts)

The pool-retirement WARN pool_hard_closed is the one to alert on for lost connections. Bootstrap credential ERRORs need operator repair; the resync WARN reports a credential replacement. The invalidation failure lines retain exception-message text (the malformed-message line caps it at 200 characters); they do not follow §3.4G's class/SQLSTATE-only shape, and their remaining review is tracked in #329. pool_hard_closed means a statement outlived datapipelines.datasources.retire-ceiling-seconds (Configuration §3.26) — either a genuinely hung query, or a ceiling set below what this deployment's statements really take. It carries the number of connections it took down, which the matching datapipelines.datasource.pool.hard_closed counter deliberately does not (an unbounded tag value, §4.3).

3.4B The mail events (137)

The notices of Auth §5A.8 leave two kinds of trace: the AUDIT rows mail.sent / mail.failed (Enums §15), and these structured log lines. None carries an address beyond its domain, and none ever carries a body.

Level event= When Fields
INFO mail.configured / mail.disabled Boot: which MailSender was wired — the transport (host, port, starttls, the from DOMAIN, the ops-to COUNT, whether a stream header is set) or the no-op see When
INFO mail.skipped Mail is not configured and a notice was asked for — one line per skipped send, the no-op sender's only output kind, domain
INFO mail.accepted The transport accepted a notice (the mail.sent audit row's log twin) kind, domain, message_id
DEBUG mail.already_claimed A second attempt for one message identity found the claim row and stopped — the "never twice" rule working kind, user, act
WARN mail.send_failed The transport refused or failed a notice; the same error is on the mail_sends row and in the mail.failed audit row kind, user, error
WARN mail.send_retry A connect failure (nothing delivered) is being retried in place (158, #121 — bounded: 3 attempts); the row is marked from the FINAL outcome kind, attempt, error
WARN mail.dispatch_failed The pool task itself threw (a claim-row update failed, not the transport) kind, user, error

send_failed is the one to alert on — a welcome mail that failed is a user who cannot log in until an admin resets. The admin screen shows the failure too; this line is for the operator who watches logs rather than screens.

3.4C The LAKE instance events (152)

A LAKE datasource's pool owns one embedded DuckDB instance per pool generation (Datasources §5.2): a retained owner connection is opened at pool build, initialized once, and released after the Hikari pool has shut down. Nine events name that lifecycle and its failures; the ERROR is the one that means a pool needs rebuilding, and the WARNs are what the operator should read.

Level event= When Fields
INFO lake.instance_opened A pool generation opened and initialized its instance owner — one per pool build datasource, generation
INFO lake.instance_closed The generation's release completed, after the Hikari pool's shutdown; retained_owner_closed says whether the driver actually closed the retained owner connection (false ⇒ a lake.instance_owner_close_failed WARN preceded it) — the instance is freed once the last handle drops datasource, generation, retained_owner_closed
ERROR lake.instance_owner_lost The retained owner connection is closed while the pool is still live (a driver fault, not a retire); every new lease is refused with SQLSTATE 08003 until the pool is rebuilt — logged once per generation datasource, generation, message
WARN lake.instance_owner_close_failed The driver refused to close the retained owner connection at release — an SQLException or a nonfatal RuntimeException, contained the same way; one attempt, no retry, nothing propagates (the release runs inside the shared manager's loop over every pool, and one lake's driver fault must not stop the next pool's cleanup); the physical connection is the driver's residual, reported once datasource, generation, error, message
WARN lake.instance_handle_exhausted A physical connection refused every close the ownership protocol permits — its holder's own attempt, the generation's one attempt, or the one hand-off retry — and no actor is left: it stays registered and counted in liveDuplicates as the generation's residual. Logged by whichever actor made the last attempt, with the driver's error datasource, generation, attempts, error, message
DEBUG lake.instance_refused_duplicate_close_failed A duplicate created just as its generation retired was refused at registration and its creator's close of it failed; the handle stays registered — the release (if it has not run) or the creator's own retry (if it has) takes it, and exhaustion is reported by lake.instance_handle_exhausted datasource, generation, error
WARN lake.instance_handles_closed At the generation's release, physical connections were still registered to it — ones HikariCP never accepted (their creation straddled the shutdown and the closed bag refused them), abandoned to a borrower at its shutdown ceiling, or whose earlier close the driver refused — and the generation released them. The counts distinguish what happened: closed (the generation's own close was confirmed), already_closed (found closed by something the wrapper never saw, e.g. an executor-driven abort), in_flight (a borrower's close was mid-flight on another thread; the last word was handed to it), close_failures (the driver refused — the honest residual, still registered) datasource, generation, handles, closed, already_closed, in_flight, close_failures, error (the last driver refusal, empty when none), message
WARN lake.view_failed One registered table's view could not be created at instance init (109 §A); it is recorded on the table's registry row and skipped, the surviving views serve datasource, table, error, message
WARN lake.view_outcome_record_failed The registry write of a view outcome threw; the instance still serves the surviving views, or an all-refused registry still refuses the pool build; the row keeps its previous state datasource, table, error (exception class), sql_state, message (static explanation)

instance_owner_lost is the one to alert on: it is a datasource whose engine went away underneath a live pool, and it does not self-heal — retire-and-rebuild (a datasource save, a table registration, or the reconcile) is the remedy, and the message says so. view_failed is the existing per-table isolation working (the table's row carries the error; the UI shows it); a burst of them after a registry change is a bad location or credential, not the engine. Nothing here is an audit row.

3.4D The promoter lens event (178)

A promoter sees only released, not-yet-promoted pipelines and templates (Auth §11A.1); deciding that needs the higher environment's inventory, read through a per-workspace cache (datapipelines.deployment.promotion.inventory-cache-ttl-seconds, Configuration §3.19). When the target cannot be read the lens FAILS CLOSED — every promoter read is empty, the screens say why — and one event says so per window.

Level event= When Fields
WARN pipeline.promotion.lens_unavailable The lens's inventory probe for a workspace failed — target unreachable, malformed, refusing, or no target configured — and the failure is now cached for one TTL window, during which every lensed read in that workspace answers nothing. Logged on the PROBE, never on the cached answers, so it is once per window by construction; reason is the transport class or configuration reason (ConnectException, malformed_response, no_target_configured), never the key target (base URL), reason, workspace, code (the §13 code the promotion page would have shown)
INFO pipeline.promotion.pushing After building and validating a promotion batch, immediately before pushing it; one line per push attempt, not evidence the target accepted it target (base URL), workspace, roots, templates, sets, pipelines, endpoints, visualizations, dashboards (counts)
INFO pipeline.promotion.target_refused The target returned a non-success HTTP response with a JSON body; one line per refusal read, including inventory reads. Its code is re-raised, or a missing code becomes a malformed-response error target (base URL), status, code ((none) when absent); no response message/body

Sustained repeats every window mean the sender's promotion target is down or misconfigured: promoters see empty lists everywhere until it is back, and the promotion page shows the same code. Not an audit row.

3.4E The scheduler events (#9)

The scheduler (Scheduler) logs its own work under scheduler.*. A person's actions on a schedule are AUDIT rows (Enums §15, schedule.*), and every run keeps its own trail in the database — these lines are for the operator watching the instance, never the history of record. Ids only; never a payload or a parameter value.

Level event= When Fields
INFO scheduler.started This instance began dispatching, once the application is ready threads, tick_seconds
INFO scheduler.api_mode datapipelines.scheduler.enabled=false: this instance serves the REST routes but never dispatches —
INFO scheduler.dispatched A dispatcher tick recorded something (silent otherwise) queued, catch_up, missed, overlap — the counts of that tick
INFO scheduler.reconciled A reconciler tick moved at least one run (silent otherwise) changed, watched
INFO scheduler.run_not_started A run waited out its lateness window for a capacity slot and ended not_started / capacity run_id, reason, attempts
WARN scheduler.schedule_blocked A schedule became blocked — an unknown run, or an executor refusal that asks for it; nothing fires from it until a person unblocks it schedule_id, run_id, reason
ERROR scheduler.start_failed The executor's launch threw — the run is unknown / start_failed and its schedule blocked, because nobody can say whether work began run_id, execution_id, message
WARN scheduler.start_refused The launch path refused BEFORE the execution's record existed — the run is not_started / start_refused and blocks (#253). The run's trail carries only the catalogued code; this line is where the raw exception stays run_id, execution_id, message
ERROR scheduler.inspect_refused The system identity was refused execution.read in a workspace — the reconciler cannot see outcomes there, and its runs stay running until it can. A configuration defect: the identity's fixed set is code, not data workspace_id
INFO scheduler.admission_closed Shutdown: admission closed and no launch was in progress, so the execution drain may start —
WARN scheduler.admission_wait_expired Shutdown: launches were still in progress when shutdown-wait-seconds ran out; their runs reconcile from the execution record launching, wait_ms
WARN scheduler.outcome_evidence_unreadable (#336) A finished run's persisted outcome evidence could not be read: the execution row's error_json (the run lands unknown / outcome_unreadable — it may have said instance_lost), or the last execution_aborted event's payload (the run stays aborted; only the reason is lost) execution, evidence (error_json/abort_payload), error

| INFO | scheduler.notification.enqueued (#442b) | A run transition (or a block) wrote its notice: one event row and its deliveries, in the transition's own transaction | schedule_id, run_id, notification_id, notice (the checkbox), outcome, queued, refused (<code>:<count>, or 0) | | WARN | scheduler.notification.backlog_full (#442b) | More than notifications.backlog-max deliveries were pending: the notice's remaining deliveries were written failed / backlog_full | notification_id, backlog_max | | INFO | scheduler.notification.sent (#442b) | The SMTP relay accepted one delivery — not proof it reached an inbox | delivery_id, notification_id, schedule_id, run_id, notice, domain (the recipient's domain only), attempts, code (-) | | INFO | scheduler.notification.retry (#442b) | A send provably never reached the relay (connect, 4xx, credentials); the delivery is pending again after the backoff | the same fields; code is the sanitized reason | | WARN | scheduler.notification.failed (#442b) | A delivery is finally failed: refused (5xx, invalid address), out of tries, or found with mail off or its domain outside a narrowed allowlist at send | the same fields | | WARN | scheduler.notification.uncertain (#442b) | A delivery may have been sent — the connection failed after the message went out, or a claim a dead instance left was recovered (code=interrupted). Never retried automatically; a person may resend | the same fields (a recovered claim adds message) | | ERROR | scheduler.notification.render_failed (#442b) | A notice could not be rendered (a defect); the delivery is failed / render_failed, nothing was sent | delivery_id, schedule_id, run_id, error (the exception class) | | ERROR | scheduler.notification.port_failed (#442b) | The mail port threw instead of answering (a defect); the delivery is uncertain / interrupted | delivery_id, schedule_id, run_id, error (the exception class) | | WARN | scheduler.notification.settle_lost (#442b) | A send finished after its claim had already been recovered as interrupted; the recovered state stands | delivery_id, schedule_id, run_id, message |

The notification lines carry ids, the notice and the recipient's DOMAIN; never an address, a subject or a body. A notice's history of record is the run's trail (notification_enqueued / _sent / _failed / _uncertain, whose details carry no domain either) and its delivery rows (Metadata DB §4.40).

A scheduler.schedule_blocked needs a person. A steady scheduler.run_not_started means max-concurrent-runs is too low for this instance's schedules, or runs are too long for their cadence — datapipelines.scheduler.capacity.retries (§4.1) is the leading signal.

3.4F The legacy endpoint events (#274, #286)

A published_endpoints row whose stored path fails today's grammar is RETIRED, never fatal (Metadata DB §4.13): never served, listed flagged, unpublishing it is the fix. The first two lines say so to an operator; both carry a COUNT, never a path. The remaining rows describe serving, registry invalidation and key-budget failures.

Level event= When Fields
WARN endpoint.legacy_rows Once per JVM, on the first repository query that meets a legacy row. at_least is THAT query's count — a floor, not the deployment's total (after V41 the registry warm-up reads enabled rows only, so the first sighting is usually one workspace's listing) at_least
WARN endpoint.promotion_legacy_omitted A promotion batch was built and legacy rows over its promoted pipelines were left out — the target's grammar would refuse them count
WARN endpoint.key_budget_exhausted A metered API-caller key exceeded its instance-local fixed-window budget; one line per refused request, answered 429 with Retry-After, before serving key_id, path (request application path), limit (requests/windowSeconds followed by s)
WARN endpoint.key_budget_table_saturated A new key cannot enter the bounded tracking table even after expired windows were removed; one line per unmetered admission, with the saturation counter incremented size, message (static explanation)
WARN endpoint.pipeline_not_released A matched live endpoint has no servable pipeline version; one line per refusal, answered 503. Check its pipeline release path (stored pattern), pipeline_id, message (static explanation)
ERROR endpoint.registry_pattern_unparseable A registry reload built fewer matchable endpoints than enabled rows: unparseable or reserved-category rows are omitted, surviving endpoints still serve. One line per affected reload loaded, matchable, reason (static explanation); no path
WARN endpoint.registry_invalidation_publish_failed A committed endpoint change could not be published to Redis; one line per failed publish. Peers may serve their previous snapshot until their next reload message (exception message), reason (static explanation)
WARN endpoint.registry_invalidation_malformed A pub/sub message could not be parsed and was dropped; one line per malformed message, subscriber stays alive reason=dropped, message (first 200 characters of the parser exception's message)
INFO endpoint.registry_invalidated_remotely A parsed message from another instance dropped this instance's snapshot; one line per peer message, own-origin messages are ignored. The next request reloads origin, message (static explanation)

Neither legacy-row line is an alert: the API console lists every legacy row with its reason. A steady promotion_legacy_omitted means an endpoint that will not reach the target until someone republishes it at a legal path.

The invalidation WARNs retain exception-message text; only the malformed-message diagnostic is capped. They are not §3.4G's class/SQLSTATE-only shape (#329). A sustained key-budget saturation rate means new keys are being admitted unmetered.

3.4G The persistence events (#266)

The batching writers in front of the audit log, the execution-event record and the replay log (DAG Executor §10.1, Auth §10.1A, Configuration §3.32) log under persistence.*; writer is the store (audit, execution_events, replay_log). An item is named by its ids only (item, failed_items: execution and event id, or audit event name, user id and key id) — never a payload or details (§9.2).

Level event= When Fields
WARN persistence.batch_retried A batch threw; it was retried one item at a time, in order writer, batch, committed, failed, failed_items (each with its kind), cause, sql_state
WARN persistence.write_failed A batch of ONE item threw (no retry is possible) writer, kind, item, cause, sql_state
WARN persistence.direct_write_failed A caller's own write (after record-max-wait-ms, a full queue or shutdown) threw writer, kind, item, cause, sql_state
WARN persistence.indeterminate The emitter stopped waiting after 2 × record-max-wait-ms while a write of the item was still running — it may yet land writer, items, wait_ms
WARN persistence.direct_write_abandoned The emitter's direct write never started within its bound and never will — the item is NOT written writer, item
WARN persistence.saturated The queue is full: callers are writing directly, submit is refusing. At most once per 10 s per writer — a state, not an event per item writer, queued, max_events, queued_bytes, max_bytes
WARN persistence.hook_failed A metrics hook threw; the writer contained it — the item's outcome is unchanged, only that hook's own count is lost. At most once per 10 s per writer writer, hook, cause
ERROR persistence.writer_died A writer thread ended on an Error; its batch was failed so no caller waits on it, and its partition's later items are written directly by their callers writer, partition
DEBUG audit.write Every audit row, the path it takes: transactional (inside the caller's transaction, on its connection), batched (the audit writer), direct (no writer — the default, datapipelines.persistence.audit.enabled: false, or enabled: false) audit_event, path
INFO shutdown.persistence_drain_started Shutdown, after the web server's graceful drain: every writer starts its bounded drain, in parallel writers
INFO persistence.drained / shutdown.persistence_drained A writer flushed everything it held writer (lost, in_flight on the second)
WARN persistence.drain_incomplete A writer's drain reached shutdown-drain-ms with items left: lost were never written (only submit items can be — nothing awaits them), in_flight were inside a commit and may or may not have landed writer, lost, in_flight, drain_ms

A failure is named by cause (the exception's simple class name) and sql_state (the first non-null SQLSTATE among at most 16 causes, unchanged only if it matches ASCII ^[0-9A-Za-z]{1,5}$; invalid for any other shape, without trying later states, and none when no state is found) — never by the exception's message or its cause chain, which a store fills with the refused row (Postgres's DETAIL and CONTEXT; PgJDBC's batch exception quotes the bound values). 23xxx is a row the store refuses, 08xxx a connection lost (#266b). The emitter and the audit sink keep their own lines too — Durable event … not written (kind), SSE event log append failed … (replay will be incomplete), audit_log write failed … kind=… (on the direct path … cause=… sql_state=…) — the ones operators have always searched for. A steady persistence.saturated or a rising datapipelines.persistence.fallbacks{reason=timeout} (§4.1) means the store cannot keep up; batch_retried with kind=poison means rows the store refuses, which no retry will fix.

The same class/SQLState rule applies to both lake outcome-recorder failure paths (§3.4C), the write-back rollback warning and the example-content import failure line (#329, first slice): their error field uses FailureShape.cause, with sql_state=none when absent, and no exception message or throwable attachment. This slice leaves the remaining #329 inventory for separate review.

3.4H The scheduled jobs' thread (#316)

Every @Scheduled job — the stale-execution sweep (every 15 s), the pool reaper (every 5 s), the dashboard refresh sweep (every 60 s) and the hourly retention sweep — runs on ONE thread of its own, dp-scheduled (SweepSchedulingConfiguration's taskScheduler), and nothing else runs there. Their lines therefore carry thread: dp-scheduled (§3.1): execution.swept / execution.sweep_failed, execution.events_purged / execution.event_retention_failed, keys.purged, the audit.retention* lines and retention.step_failed (§7), the dashboard and parameter housekeeping lines (§3.4J, §3.4M), and §3.4A's datasource.pool_hard_closed. Until #316 they carried dp-sse-log: the jobs ran on the SSE log streamer's thread (REST API §10.3's replays), so a slow tick stalled every replay and a burst of replays delayed the sweep.

Level event= When Fields
INFO shutdown.scheduled_jobs_stopped Shutdown, after the execution drain and every writer's drain (§3.4G): the jobs' scheduler has stopped and its thread has ended. No tick STARTS once the application begins to close; a tick already running finished before this line, while the metadata pool was still open thread
WARN shutdown.scheduled_jobs_incomplete The same point, but a tick was still running — it outlived spring.lifecycle.timeout-per-shutdown-phase (30 s by default) and a further await_ms. It is cut when the metadata pool closes, and the next tick, on this instance or another, redoes it: every job is idempotent. Since #326 the four metadata jobs' statements carry a JDBC bound (the sweep's 10 s, the retention's 25 s — under this 30 s phase), and since #636 the four housekeeping statements do too (the dashboard refresh sweep's 10 s; the retention tick's 25 s for the dashboard refreshes' DELETE and the parameter evaluations' sweep UPDATE and retention DELETE), so a tick hung on a lock fails at its bound or was cancelled at its bound (sql_state=57014) before it can outlive the phase; the pool reaper is not bounded thread, await_ms

3.4I The executor's retention events (#316 review)

The executor's two scheduled jobs on the dp-scheduled thread (§3.4H) — the stale-execution sweep (StaleExecutionSweeper, every 15 s) and the hourly retention sweep over execution_events (ExecutionEventRetention) — log these. The two failure lines name the store failure by error (its class) and sql_state, never its message (#321, the §3.4G rule; error= is the scheduled-job mould's field name — audit.retention_failed — where §3.4G's writers say cause=). Catalogued at the 316 merge: §3.4H cited two of these names before any table defined them, and the docs audit (whose execution event family joined in the same commit — execution is also a permission family) refused the citation.

Level event= When Fields
INFO execution.events_purged A retention tick deleted at least one execution_events row for executions completed before the cutoff (nothing is logged for an empty tick) count, cutoff
WARN execution.idempotency_release_failed One release attempt for a never-started REST or MCP execution failed; the original refusal still reaches the client and the key expires by TTL user, execution, error (class only; no key or exception message)
WARN execution.sweep_failed The stale sweep's UPDATE threw, or was cancelled at its bound (sql_state=57014; the 10 s SWEEP_STATEMENT_TIMEOUT_SECONDS, #326); the tick is skipped and the next one retries. Counted as datapipelines.scheduled_jobs.tick_failed{job=stale_sweep} (§4.1) cutoff, heartbeat_cutoff, error, sql_state
WARN execution.event_retention_failed The retention tick's DELETE threw, or was cancelled at its bound (sql_state=57014; the 25 s RETENTION_STATEMENT_TIMEOUT_SECONDS, #326) — the DELETE is one statement, so a cancel deletes nothing; the next tick retries. Counted as datapipelines.scheduled_jobs.tick_failed{job=event_retention} (§4.1)
ERROR retention.step_failed One step of the hourly retention sweep threw past its own handling (step = execution_events, dashboard_refreshes, parameter_evaluation_sweep, parameter_evaluations, keys or audit_log); the sweep runs its remaining steps and the next tick retries. For keys this is the keys purge's only failure line: its statements carry the retention bound, and a cancel (sql_state=57014) rolls the whole purge transaction back. Counted as tick_failed under the step's job (§4.1): execution_events → job=event_retention, dashboard_refreshes → job=dashboard_refresh_retention, parameter_evaluation_sweep → job=parameter_evaluation_sweep, parameter_evaluations → job=parameter_evaluation_retention, keys → job=keys_purge, audit_log → job=audit_retention. Every step but keys counts the store faults it catches itself (its own WARN line); this line and its count are only what escaped that catch, so no failure is counted twice (#636) step, error, sql_state
INFO execution.swept A stale-execution sweep (15 s fixed delay on dp-scheduled) marked at least one stale RUNNING row ABORTED with pipeline.execution.instance_lost; silent for zero rows. A stale heartbeat, or an old start with no heartbeat, triggers the UPDATE count, cutoff, heartbeat_cutoff, message (static explanation)

3.4J The dashboard refresh events (#10 L2)

The dashboard runtime (Dashboards §5) logs at these points. The WARN/ERROR failure rows name their cause by class (error=) and, for a store, by SQLState — never by message (the 321 rule, §3.4G). The two DEBUG stream-write diagnostics attach a throwable; its message and stack remain diagnostic text, so this catalogue does not claim every dashboard log message is sanitized. No row below deliberately logs a selection, binding value, result row or execution message as a field.

Level event= When Fields
INFO dashboard.refresh_finished A refresh ended (any status) refresh_id, status, targets
WARN dashboard.refresh_never_admissible A refresh was refused at once because its distinct executions exceed the smaller of max-executions-per-refresh and the executor's instance ceiling, so it can never be admitted here (#762): the 429 dashboard.refresh.saturated carries details.reason=never_admissible, no refresh row is written, and the refusal counts under reason=saturated (§4.1). One line per refused attempt; key is the configuration key to raise (Configuration §3.34) workspace_id, dashboard_id, invocations, ceiling, key
ERROR dashboard.refresh_work_failed The refresh's work threw unexpectedly; the row is still closed FAILED refresh_id, error
ERROR dashboard.refresh_end_failed A terminal step (finish — the row write — or audit) threw; the remaining steps and the last frame still ran refresh_id, step, error
ERROR dashboard.refresh_link_failed The dashboard_refresh_executions link could not be written for a started source; the execution carries on unlinked refresh_id, error
WARN dashboard.source_not_started A source's RUNNING row could not be written (fail-closed): the source did not run refresh_id, source, error
INFO dashboard.refresh_stream_cut A stream was cut because its subscriber's standing no longer holds (P4); the refresh keeps running refresh_id, verdict
INFO dashboard.refresh_client_gone A stream's client vanished before refresh_completed; the disconnect grace begins refresh_id, grace_seconds
INFO dashboard.refresh_grace_elapsed The grace elapsed: the refresh is aborted refresh_id, action=abort
WARN dashboard.refresh_abort_cancel_failed The abort route could not cancel one of the refresh's executions through the executor refresh_id, error
WARN dashboard.refresh_abort_flag_unreadable / dashboard.refresh_abort_flag_not_cleared Redis could not be read / cleared for the refresh-level abort flag (dp:refresh-abort:{refresh_id}); an unreadable flag reads as "not requested" and the next poll retries refresh_id, error
WARN dashboard.refresh_swept The stale sweep closed a RUNNING refresh past its deadline as TIMED_OUT (metadata-db §8.4) refresh_id
WARN dashboard.refresh_sweep_failed The sweep's UPDATE threw, or was cancelled at its bound (sql_state=57014; the 10 s REFRESH_SWEEP_STATEMENT_TIMEOUT_SECONDS, #636) — the UPDATE is one statement, so a cancel closes nothing; the next tick retries. Counted as datapipelines.scheduled_jobs.tick_failed{job=dashboard_refresh_sweep} (§4.1) error, sql_state
INFO dashboard.refreshes_purged The retention step deleted finished refreshes (nothing is logged for an empty tick) count, retention_days
WARN dashboard.refresh_retention_failed The retention DELETE threw, or was cancelled at its bound (sql_state=57014; the 25 s RETENTION_STATEMENT_TIMEOUT_SECONDS, #636) — the DELETE is one statement, so a cancel deletes nothing; the next tick retries. Counted as datapipelines.scheduled_jobs.tick_failed{job=dashboard_refresh_retention} (§4.1) error, sql_state
WARN dashboard.refresh_start_marker_unwritable Redis failed while registering one in-flight start marker or its bounded per-principal index; the start proceeds without a reliable marker. A pre-row abort can therefore answer 404 refresh_id, error (exception class)
WARN dashboard.refresh_start_marker_unreadable One Redis marker lookup failed; it returns no marker and grants no abort authority on the fault. One line per failed lookup refresh_id, error (exception class)
WARN dashboard.refresh_start_marker_not_cleared One Redis cleanup of the start marker/index failed; their TTLs expire the residue. One line per failed clear refresh_id, error (exception class)
WARN dashboard.refresh_stream_authority_failed The subscriber-authority check threw; access is treated as revoked with no execution link (fail-closed). One line per failed check error (exception class)
WARN dashboard.refresh_tick_failed The registry heartbeat/disconnect-grace tick threw; the timer survives and the next tick retries at datapipelines.sse.heartbeat-interval-seconds. One line per failed tick error (exception class)
DEBUG dashboard.refresh_stream_gone An SSE frame or heartbeat write threw IOException, or a heartbeat found the emitter already completed; marks the stream disconnected. One line per failed write; throwable attached refresh_id, while (frame event name or heartbeat), state=completed on the already-completed heartbeat variant
DEBUG dashboard.refresh_stream_closed A frame write found the emitter already completed (IllegalStateException); marks the stream disconnected. One line per failed frame write; throwable attached refresh_id, while (frame event name)

3.4K The live-write outage events (#336)

pipeline_executions.heartbeat_at is the stale-execution sweep's primary signal (Metadata DB §8.3): a store that refuses the live writes makes a LIVE execution look dead to the sweep on another instance. JdbcExecutionProgress logs its write failures by §3.2's degradation rule — once per execution per outage, on the healthy → failing transition, with an INFO when a write answers again — because a heartbeating execution writes at a fixed cadence and a per-refusal line would be a second outage in the log pipeline. The failures between the transition lines are counted by the metric, not logged.

Level event= When Fields
WARN execution.progress_write_failed An execution's first refused live write after a healthy one — a heartbeat or a progress write (kind says which); a steady presence means the metadata store is refusing writes while executions run, and the stale sweep on another instance is reading their heartbeats going stale execution, kind, error, sql_state
INFO execution.progress_write_recovered A live write answered again after the outage — one line, per execution execution

A refusal is named by error (its class) and sql_state (FailureShape, the §3.4G rule), never the driver's message.

3.4L The selector-lease cleanup events (#336)

ReadOnlyStatementLease's cleanup endings (Datasources §5.3) log one WARN per refused ending — a statement close, a connection close, or a statement cancel in abandon(). The cancelled-connection path's discard runs unconditionally; the WARN is evidence, not a reason to keep a connection in service.

Level event= When Fields
WARN datasource.lease_cleanup_failed A selector lease's statement close, connection close or statement cancel was refused; a refused connection close DISCARDS the connection (never returned to service) datasource, operation (statement_close/connection_close/statement_cancel), error, sql_state

3.4M The parameter evaluation events (#376)

The parameter-set evaluation history (REST API §21.5, Metadata DB §8.1 retention, §8.5 sweep) logs at these points. Every failure line names its cause by class (error=) and, for a store, by SQLState (sql_state=) — never by message (the 321 rule, §3.4G); NO line carries a selection, a binding value, a row or a query's text. A history write that fails is logged at ERROR and the evaluation carries on unrecorded: that is the #376 design — the record is a side channel of the evaluation, never its master — so a steady presence of parameter.evaluation_record_failed means the metadata store is refusing the history writes while evaluations still answer.

Level event= When Fields
ERROR parameter.evaluation_record_failed One history write was refused by the store (DataAccessException); the evaluation continues unrecorded for that write write (start/query_queued/query_ended/end/abandoned_queries), evaluation_id, workspace_id, parameter_set_id, error, sql_state (none when no SQLState is in the cause chain — FailureShape's spelling, never null)
WARN parameter.evaluation_defect The evaluator hit an unexpected RuntimeException (a defect, never an author's problem); the record closes FAILED with no code and the exception is rethrown evaluation_id, error
ERROR parameter.evaluation_failed The observed (page) route's evaluation threw a non-domain exception; its failure frame carried the stand-in code only, so the cause is logged here by class and SQLState, with no throwable attached (its message can carry SQL or a value); the stack is the parameter.evaluation_failed_cause DEBUG row's. A parameter.evaluation_defect precedes it when the throw came from the evaluator itself evaluation_id, error (the class's binary name), sql_state (FailureShape's spelling; none when absent)
DEBUG parameter.evaluation_failed_cause The throwable behind a parameter.evaluation_failed line, stack attached (diagnosis only; DEBUG is off by default) evaluation_id
INFO parameter.evaluation_refused The observed route's evaluation was refused as a whole (a domain error — timeout, response_too_large); its evaluation_failed frame was already written evaluation_id, code
INFO parameter.evaluation_ended The stream's last frame: the evaluation ended (any outcome) and the stream closed evaluation_id, outcome, delivered (whether the last frame reached a connected, still-authorised client)
WARN parameter.evaluation_observer_failed The stream observer threw while receiving an event; the stream is closed and the evaluation carries on (the stream is a side channel) while (the event's class), error
INFO parameter.evaluation_stream_cut A stream was cut because its subscriber's standing no longer holds (expired session, revoked key, lost permission); the evaluation keeps running evaluation_id, verdict
WARN parameter.evaluation_stream_authority_failed The authority's check threw; the stream is treated as revoked (fail-closed) error
INFO parameter.evaluation_client_gone A stream's client vanished before the evaluation ended; the disconnect grace begins evaluation_id, grace_seconds
INFO parameter.evaluation_grace_elapsed The grace elapsed with no client: the evaluation is aborted evaluation_id, action=abort
WARN parameter.evaluation_tick_failed The stream registry's heartbeat/grace tick threw; the timer survives and the next tick retries error
DEBUG parameter.evaluation_stream_gone A frame or heartbeat write found the client gone (IOException; with state=completed, the emitter was already completed); the stream is marked disconnected. Carries the exception's stack trace evaluation_id, while (the frame's SSE event name, or heartbeat), state=completed on one heartbeat variant
DEBUG parameter.evaluation_stream_closed A frame write found the emitter already complete (IllegalStateException); the stream is marked disconnected. Carries the exception's stack trace evaluation_id, while (the frame's SSE event name)
WARN parameter.evaluation_swept The stale sweep closed a RUNNING record past its deadline as INCOMPLETE (metadata-db §8.5); one line per record evaluation_id, message
WARN parameter.evaluation_sweep_failed The sweep's UPDATE threw, or was cancelled at its bound (sql_state=57014; the 25 s RETENTION_STATEMENT_TIMEOUT_SECONDS, #636) — the UPDATE is one statement, so a cancel closes nothing; the next tick retries. Counted as datapipelines.scheduled_jobs.tick_failed{job=parameter_evaluation_sweep} (§4.1) error, sql_state
INFO parameter.evaluations_purged The retention step deleted finished evaluation records (nothing is logged for an empty tick); the plural twin of dashboard.refreshes_purged count, retention_days
WARN parameter.evaluation_retention_failed The retention DELETE threw, or was cancelled at its bound (sql_state=57014; the 25 s RETENTION_STATEMENT_TIMEOUT_SECONDS, #636) — the batch is one statement, so a cancel deletes nothing; the next tick retries. Counted as datapipelines.scheduled_jobs.tick_failed{job=parameter_evaluation_retention} (§4.1) error, sql_state
WARN parameter.evaluation_retention_incomplete A retention batch came back full (a backlog): records older than the cutoff remain and the next tick takes the next batch count, message
ERROR parameter.selector_statement_abandoned The evaluate's await was cancelled (its withTimeout deadline, or the caller — #375's disconnect-grace abort, a shutdown) while the selector statement still ran; the statement is cancelled best-effort, its connection discarded, the worker keeps its slot until the driver returns (parameters.selectors.abandoned counts it). One line, no stack trace, never the SQL or a bind set, parameter, datasource, cause (deadline/caller)

3.4N The audit retention events (#310, #443)

Audit retention is the last step of the hourly fixed-delay retention sweep on dp-scheduled (§3.4H, §7). Each tick reads one cutoff from the database clock and deletes bounded batches. These lines describe that tick's committed deletions, including a tick that subsequently fails; they are not audit-row contents. Failures carry class and SQLSTATE under §3.4G's rule. A continuing incomplete/failed pattern means retention is falling behind or the metadata store is unavailable.

Level event= When Fields
INFO audit.retention Once per tick that deleted at least one row, including a tick that later failed; silent for zero deletions purged, cutoff, batches
WARN audit.retention_failed A metadata-DB operation threw, or was cancelled at its bound (sql_state=57014; the cutoff read and every batch carry the retention's 25 s bound, #326 — a cancelled batch rolls back alone); the tick stops, prior batches stay committed and counted, the next tick retries. Once per failed tick purged, batches, cutoff (null if reading it failed), error (exception class), sql_state
WARN audit.retention_incomplete The tick reached its batch ceiling or time budget after a full batch, rather than observing a short batch. Once per bounded tick; the next tick continues with a fresh cutoff, without an additional query proving the backlog size purged, cutoff, batches, reason (batch_ceiling/time_budget), message (static explanation)

audit.write is catalogued in §3.4G; these three rows complete the four-name audit namespace.

3.4O The execution shutdown drain events (#443)

These lines occur once per execution-drain stop, after scheduler admission closes and before the web server's graceful shutdown and persistence drain (§3.4G). Readiness flips before cancellation; the drain then cancels local executions through their ordinary statement/job path and waits for deregistration, retrying cancellation during the wait. The default flush bound is 20 s, with 100 ms polls. This is a lifecycle sequence, not a periodic job. The persistence and scheduled-job shutdown outcomes remain in §3.4G/H.

Level event= When Fields
INFO shutdown.readiness_refused Readiness was published REFUSING_TRAFFIC before cancelling work; /ready now reports 503 message (static explanation)
INFO shutdown.drain_cancelled The first cancellation pass returned; live is the registry's remaining count at logging time, not the number cancelled live, reason=shutdown
INFO shutdown.drain_complete The bounded wait ended with no local executions registered; their deregistration follows terminal bookkeeping attempts, not a guarantee every store write succeeded —
WARN shutdown.drain_incomplete Executions remain registered at the flush deadline; their rows may remain RUNNING after exit and need the stale sweep. One alternative outcome to drain_complete live, flush_timeout_ms, message (static explanation)

3.5 Log destination

  • Stdout by default — collected by container runtime (Docker / k8s) and shipped to the operator's log aggregator (CloudWatch, Stackdriver, Loki, ELK, etc.).
  • No file logging in container. Operators choose the aggregation strategy.
  • Local dev: human-readable console output — the console value of datapipelines.observability.logging.format (configuration.md §3.15), the value deploy/env/defaults.env ships for local runs (#337).

4. Metrics

4.1 Metric naming

All application metrics are prefixed with datapipelines.. Framework-supplied metrics keep the names their instrumentation gives them — notably Spring Boot's http.server.requests and HikariCP's hikaricp.*, which are not renamed into the datapipelines. namespace (renaming them would break every off-the-shelf dashboard and Boot's own auto-configuration).

Tag sets below are the complete, normative set for each metric — adding a tag is a spec change, not an implementation detail (§4.3).

Metric Type Tags Description
datapipelines.executions.total counter status (success/failed/aborted), pipeline_id Execution count
datapipelines.executions.aborted counter reason (client_disconnect/cancelled/shutdown) Aborted executions by trigger — the three D7 paths (dag-executor §15.3, enums ExecutionStatus)
datapipelines.executions.duration timer pipeline_id Execution wall-clock duration
datapipelines.executions.concurrent gauge (none) Currently-running executions; bound by the execution-slot bean, once per instance
datapipelines.executions.lifecycle_write_failed counter (none) (#311) The terminal UPDATE of pipeline_executions failed, outlived its bound, or matched no row (the RUNNING insert abandoned or never landed — #325) (datapipelines.executor.lifecycle-write-timeout-seconds, Configuration §3.2): the row is left RUNNING for the stale sweep to reap, so this counter is how an operator tells an unrecorded outcome apart from silence. The companion WARN names the execution id. The RUNNING insert's failure is deliberately NOT counted here — for scheduled runs it is the fail-closed refusal the scheduler records (record_unwritable), for interactive ones a WARN — the counter exists because a terminal row that stays RUNNING otherwise looks exactly like a dead instance
datapipelines.scheduled_jobs.tick_failed counter job (stale_sweep/event_retention/audit_retention/keys_purge/dashboard_refresh_sweep/dashboard_refresh_retention/parameter_evaluation_sweep/parameter_evaluation_retention) (#326, #636) A tick of one of the eight metadata @Scheduled jobs failed — a statement that threw, or one cancelled at its JDBC bound (sql_state=57014 in the companion line tells the two apart): the stale sweep's UPDATE (10 s, execution.sweep_failed), the execution_events DELETE (25 s, execution.event_retention_failed), the audit retention's cutoff read and batches (25 s, audit.retention_failed), the keys purge's FK read and two DELETEs (25 s, retention.step_failed step=keys), the dashboard refresh sweep's UPDATE (10 s, dashboard.refresh_sweep_failed), the dashboard refreshes' retention DELETE (25 s, dashboard.refresh_retention_failed), the parameter evaluations' sweep UPDATE (25 s, parameter.evaluation_sweep_failed) and retention DELETE (25 s, parameter.evaluation_retention_failed). A retention-tick step's failure that escapes its own catch is counted once by retention.step_failed instead, never twice. The bounds are code constants beside the cadences, not configuration keys (Configuration §3.11). The tag is the closed ScheduledJob set; each job registers its series when it is built, so a deployment reports 0 rather than an absent series. A steady stale_sweep rate means crashed instances' rows are not being reaped
datapipelines.executions.progress_write_failed counter (none) (#336) A live heartbeat or progress write failed while the execution runs — the row's heartbeat_at and node_stats_json lag the live work, and another instance's stale sweep reads the stale heartbeat as instance loss (Metadata DB §8.3). Counts every refusal; the WARN is once per execution per outage (§3.4K), so between the transition lines the counter is the only evidence. No tags: one closed condition
datapipelines.nodes.duration timer pipeline_id, node_id, source Per-node duration
datapipelines.nodes.rows_out counter pipeline_id, node_id Rows emitted by node
datapipelines.staging.rows counter (none) Total rows staged across all executions
datapipelines.staging.bytes counter (none) Total bytes staged
datapipelines.staging.active_tables gauge (none) Current H2 tables across all in-flight executions
datapipelines.datasource.pool.active gauge datasource_name Active connections in pool
datapipelines.datasource.pool.pending gauge datasource_name Threads waiting for a connection
datapipelines.datasource.pool.timeout_total counter datasource_name Pool-acquisition timeouts
datapipelines.datasource.pool.retired counter datasource_name Pools taken out of the live map by an edit, a delete, a peer's invalidation message or a reconcile (Datasources §5.2). One increment per retirement, not per connection
datapipelines.datasource.pool.hard_closed counter datasource_name Retired pools closed at the retire-ceiling-seconds ceiling with connections still out — a statement outlived the ceiling and lost its connection. A subset of retired; a non-zero rate means either a genuinely hung query or a ceiling set below what this deployment's statements really take. Each one also logs the §3.4A datasource.pool_hard_closed WARN
datapipelines.templates.render.duration timer template_id, template_version Template render time
datapipelines.templates.cache.hits counter (none) Template cache hits
datapipelines.templates.cache.misses counter (none) Template cache misses
http.server.requests timer method, uri, status, outcome HTTP request duration. Spring Boot's own metric — unprefixed. uri is the templated path (/api/v1/executions/{id}), never the expanded one.
datapipelines.dashboard.refreshes counter status (COMPLETED/PARTIAL/FAILED/ABORTED/TIMED_OUT) (#10 L2) Dashboard refreshes by terminal status, counted when the refresh ends (Dashboards §5). A refresh refused at admission is not one — see …refresh.refused
datapipelines.dashboard.refresh.duration timer status A refresh's wall-clock time from admission to its terminal row
datapipelines.dashboard.refresh.refused counter reason (saturated/stream_limit) Refreshes refused before they started: saturated is the 429 dashboard.refresh.saturated (no row written), stream_limit the per-user stream cap
datapipelines.dashboard.refreshes.active gauge (none) Refreshes admitted and not yet ended on THIS instance (the admission counters are JVM-local, Configuration §3.34)
datapipelines.dashboard.executions.reserved gauge (none) Dashboard executions reserved by those refreshes, against max-concurrent-dashboard-executions-per-instance
datapipelines.mcp.tool.calls counter tool_name, status MCP tool invocations
datapipelines.promotion.lens.inventory counter outcome (hit/miss/unreachable) The promoter lens's inventory reads (178, §3.4D): hit served from the per-workspace cache window, miss probed the target and got an inventory, unreachable probed and failed (the failure is then cached for the window, so unreachable counts windows, not reads). A steady unreachable rate with no miss is a dead target; hit/miss ≫ 1 is the cache doing its job
datapipelines.auth.login.attempts counter outcome (success/domain_not_allowed/user_inactive/oidc_error) Login attempts. Outcomes mirror the audit events in Auth §10.1. There is no lockout outcome: authentication is OIDC-only, the product stores no local passwords, and no lockout mechanism exists to count.
datapipelines.auth.api_key.validations counter outcome (success/invalid/expired/revoked) API key validations
datapipelines.auth.login_rate_limit.saturated counter (none) Login rate-limit admissions made with the tracked-client table FULL — the limiter's fail-open branch (Auth §11.5). The table is bounded at 10,000 client addresses; past that a new client is admitted unmetered rather than the map grown, so a spoofed-IP flood cannot exhaust the heap. Registered eagerly, so a healthy deployment reports 0 rather than an absent series. Any sustained non-zero rate means the login surface is unmetered for new clients right now. No tags: it is one closed condition, and §4.3 forbids inventing a dimension that is not a bounded set
datapipelines.audit.retention.purged counter (none) audit_log rows deleted by the audit-log retention job (§7, #310) — the sum of every tick's purged=, a batch that failed mid-tick included (its committed rows are gone). Registered when the job is built, so a deployment reports 0 rather than an absent series. The steady-state rate is roughly the write rate a retention window ago; a flat line on a deployment older than datapipelines.audit.retention-days means the job is not running

Result delivery, SSE and idempotency (REST API §7, D9):

Metric Type Tags Description
datapipelines.result.bytes_written counter (none) Bytes materialized into the Redis result store. Paired with result.max-size-bytes — the ratio of writes near the cap is the signal that a deployment is using result delivery as a bulk-data path (an explicit NOT-goal).
datapipelines.result.writes counter outcome (stored/too_large/storage_unavailable) Result-store write attempts; the failure outcomes correspond 1:1 to result.too_large and result.storage_unavailable.
datapipelines.result.cursor.reads counter format (json/arrow/csv), outcome (hit/expired/not_found) Cursor endpoint reads. expired rising relative to hit means the TTL is set too low for how clients actually page.
datapipelines.result.expiries counter (none) Results that reached TTL without ever being read past the inline first page.
datapipelines.result.size distribution summary (none) Result size in bytes — percentiles inform result.max-size-bytes tuning.
datapipelines.sse.streams.active gauge (none) Currently-open SSE execution streams
datapipelines.sse.stream.duration timer close_reason (completed/failed/aborted/client_disconnect/revoked/expired) Lifetime of an SSE stream. client_disconnect here is what feeds the disconnect-grace cancellation path (D7). revoked (#230, security-assurance P4) is a stream cut by the subscriber-authority re-judgement — a policy cut whose execution deliberately KEEPS running, never a disconnect, so the cancellation path never sees it. expired (#263) is the same policy cut where the refusal was the subscriber's validated token passing its exp — the same final comment and the same kept-running execution, a different credential fact, so an expired token is never counted as a standing revocation or a disconnect. The timer covers the LIVE stream; the log-served streams (the §10.3 replay and the idempotent-retry follow) record no duration, and carry the same reason in their cut's log line instead — close_reason=expired or close_reason=revoked, from the one verdict that refused (#293).
datapipelines.idempotency.cache.hits counter (none) Requests served from a stored idempotent response
datapipelines.idempotency.conflicts counter (none) idempotency.key_reused_for_different_request rejections

Scheduler (#9, Scheduler) — the scheduler's state is watched here, not through a health component (§6.1):

Metric Type Tags Description
datapipelines.scheduler.occurrences counter outcome (queued/catch_up/missed/overlap) Occurrences the dispatcher recorded: queued a run, catch_up a run under the latest policy, missed one summary row per outage (however many occurrences it covers), overlap an occurrence skipped because a run was still active. A flat line while schedules exist means no instance is dispatching
datapipelines.scheduler.runs.finished counter state (succeeded/failed/cancelled/aborted/unknown/not_started/skipped) Runs reaching a final state. A run whose unknown is later settled by the real outcome counts once for each
datapipelines.scheduler.capacity.retries counter (none) Admissions refused for capacity and retried 30 s later (R4). Rising means max-concurrent-runs is saturated
datapipelines.scheduler.notifications.sent counter (none) #442b: schedule notices the SMTP relay accepted, one per recipient
datapipelines.scheduler.notifications.failed counter (none) #442b: deliveries finally failed at send — refused, out of tries, or mail off / domain narrowed out at send. Deliveries refused at enqueue (allowlist, backlog) are rows, not this counter
datapipelines.scheduler.notifications.uncertain counter (none) #442b: deliveries that may have been sent (the connection failed after the data, or an interrupted claim). Each needs a person's look before a resend
datapipelines.scheduler.runs.in_flight gauge (none) Scheduled executions holding a capacity slot on this instance, at most max-concurrent-runs

Persistence batching (#266, §3.4G) — one set per batching writer, every metric tagged store (audit/execution_events/replay_log), bound by WebMetrics.bindPersistence. The audit set is bound even while the audit writer is switched off (the default, #266b) and then reads zero — zero batches means the direct path, not a missing meter:

Metric Type Tags Description
datapipelines.persistence.batch.size distribution summary store Items per committed batch (singles retried after a failed batch count as batches of one)
datapipelines.persistence.batch.duration timer store The store's time to commit one batch
datapipelines.persistence.lag timer store Per item, enqueue to durable — the time a caller waited for its own row
datapipelines.persistence.queue.depth gauge store Items admitted and not yet finished (queued or inside a commit); bounded by queue-max-events
datapipelines.persistence.queue.bytes gauge store The same in bytes; bounded by queue-max-bytes
datapipelines.persistence.failures counter store, kind (poison/write_failed/indeterminate/abandoned/drain_lost) Items not written, or not known to be. poison: the store refused the row (skipped, its batch committed). write_failed: the store was unavailable. indeterminate: the emitter's bounded wait ended with the write still running. abandoned: the emitter's direct write never started. drain_lost: left at the shutdown drain's deadline
datapipelines.persistence.fallbacks counter store, reason (timeout/saturated/stopped) Items a CALLER wrote itself: its batch took longer than record-max-wait-ms, the queue stayed full, or the writer had stopped
datapipelines.persistence.batches.retried counter store Batches that threw and were retried one item at a time
datapipelines.persistence.dropped counter store submit calls refused (queue full or stopped) — the only items ever dropped without a caller waiting; no production caller yet

4.2 Exposure

  • Micrometer's Prometheus endpoint at /actuator/prometheus — served on a separate management port (management.server.port, framework wiring key), never on the application port. Scrapers reach it over the cluster-internal network; it is NOT in auth.md §8.3's public list and no /actuator path is reachable on the app port without auth. Rationale (2026-08-07 security review MEDIUM-7): §4.3's metric tags include pipeline_id, datasource_name and template_id — an unauthenticated metrics endpoint on the app port would publish the internal inventory that §6's bare-UP/DOWN health design exists to protect.
  • Note the asymmetry: /health, /ready and /info are served at the root of the application port, not under /actuator and not under /api/v1 (§6).
  • Other backends (Datadog, New Relic, CloudWatch) selectable via Micrometer registry config.
  • Actuator/management exposure is configured with Spring Boot's own management.* keys — datapipelines defines no key of its own for it (configuration.md §3.14 registers those framework paths; §3.15 holds this product's own observability keys).

4.3 Cardinality discipline

High-cardinality tags avoided:

  • ❌ Don't tag by user_id, execution_id, correlation_id, exception class, method name, or any raw URI/SQL string.
  • ✅ Do tag by pipeline_id, node_id, datasource_name, template_id (bounded counts), and by closed enum-valued dimensions (status, reason, outcome, format).
  • Per-execution metrics live in the execution record (database), not in the metrics system.

Normative rule: every tag value must come from a closed set — an enum, or an entity ID whose population is bounded by what the deployment stores. A tag whose values are derived from code shape (class, method, stack frame) or from input text is forbidden; that detail belongs in the log line and the trace span, which are indexed for it. §8.1 applies this rule to error counting.


5. Tracing

5.1 OpenTelemetry

Implementation status: the tracing wiring below is a design target, not shipped behavior. The application has no OpenTelemetry SDK/exporter or consumer for the reserved tracing switches; setting them does not enable tracing. See Configuration §3.15 and the deferred export work in #270.

OpenTelemetry SDK initialized in app. Auto-instrumentation via the opentelemetry-spring-boot-starter:

  • HTTP server spans for every inbound request (REST + MCP).
  • JDBC spans for every DB query (via opentelemetry-jdbc-instrumentation).
  • HikariCP spans for connection acquisition.
  • Custom spans for template rendering and node execution.

5.2 Span attributes

Every span carries:

  • correlation_id — request correlation.
  • user_id (on auth'd requests).
  • pipeline_id, execution_id, node_id (on pipeline-execution spans).
  • datasource_name (on datasource-related spans).

5.3 Exporter

  • OTLP exporter (HTTP/gRPC to collector) — vendor-neutral.
  • Operator configures the collector endpoint (OTEL_EXPORTER_OTLP_ENDPOINT).
  • No vendor lock-in.

5.4 Sampling

  • Default: probabilistic sampling at 10% for general traffic, 100% for pipeline executions (high-value, low-volume).
  • Configurable via otel.traces.sampler.* properties.

6. Health Endpoints

Authority: the payload and the paths are defined by REST API §11. This section is the operator-facing reading of that contract; where the two ever differ, rest-api wins.

All three endpoints are served at the root — /health, /ready, /info — not under /api/v1 and not under /actuator. Probe configuration (k8s livenessProbe/readinessProbe) targets those paths directly.

6.1 /health

Liveness probe. Returns 200 OK with the service status. No auth required.

{
  "status": "UP",
  "version": "1.2.3",
  "components": {
    "database": "UP",
    "redis": "UP",
    "h2_factory": "UP"
  }
}
  • version is top-level, not a component.
  • Component keys are snake_case and the set is exactly these three: database (metadata DB connectivity), redis (result store / idempotency / event log connectivity), h2_factory (can create a staging H2 instance).
  • No scheduler component. db-scheduler's own indicator is disabled (management.health.db-scheduler.enabled: false, Configuration §3.29): it reports DOWN on an API-mode instance, which never starts the scheduler, and a stalled dispatcher is not a reason to restart a pod. Watch §4.1's scheduler metrics instead.
  • No diskSpace component. Boot's default disk-space indicator is disabled: nothing in this architecture writes to local disk (logs go to stdout §3.5, staging is in-memory H2, results live in Redis), so a disk-space signal would report on something the service does not depend on — and, being DOWN on a full container filesystem, would restart a perfectly healthy pod.

6.2 /ready

Readiness probe. Returns 200 OK when the service can serve traffic (everything in /health plus warm-up complete), 503 otherwise.

Returns 503 during startup (until Spring Boot signals ready) and during shutdown (draining) — the drain window is where in-flight executions are given up to execution-timeout-seconds before stragglers are cancelled (deployment.md, D7).

6.3 /info

Build info at the application-port root. No auth required. Key names are the contract:

Key Value Presence
version Build version string Always
build_time Build timestamp (ISO 8601 instant), supplied at build time (-Pdatapipelines.buildTime=<instant>; app.sh's image build passes the moment it builds) Absent when not supplied — same rule as commit. A plain Gradle build no longer stamps Instant.now(): that made the app's jar new on every invocation and re-ran everything downstream of it (#292).
commit Commit hash, supplied at build time (-Pdatapipelines.commit=<sha>) Absent when not supplied — never "unknown". Clients must not assume the field exists; an operator correlating a deploy to a revision needs it true or absent, never plausibly wrong.

Bare values only — no hostnames, no paths, same discipline as /health (§6.4).

6.4 Actuator security

Only /health, /ready and /info are exposed without auth on the application port — nothing under /actuator is routable there. /actuator/health (and, with the metrics registry, /actuator/prometheus) is exposed on the separate management port only (§4.2), confined to the cluster network by deployment topology; every other actuator endpoint is disabled. Health output carries no credential, hostname or JDBC URL — component values are bare UP/DOWN strings precisely so an unauthenticated probe surface cannot leak topology (§9.2).


7. Audit Log

Already covered in Auth spec §10 (event catalog §10.1, log shape §10.2). The audit log is separate from the general application log:

  • Append-only table in the metadata DB.
  • Structured events (auth events, admin actions) — the catalog in Auth §10.1 is authoritative; there are no password or lockout events, because authentication is OIDC-only.
  • Retention governed by datapipelines.audit.retention-days (configuration.md)
  • Delivery (#266). A row is committed before AuditEventSink.log returns — by default the direct INSERT (on the caller's own connection inside its transaction); with datapipelines.persistence.audit.enabled: true, batched outside a transaction (Auth §10.1A). The same holds for every execution event (DAG Executor §10.1).
  • The loss window, stated. No awaited row — audit or event — is ever acknowledged before it is durable, so a crash cannot lose a row whose caller proceeded: a caller interrupted mid-write had not proceeded (for an execution, the outcome is "not completed", never "completed without a record"). The batching writers' non-awaited submit path is the one exception — its queue at the moment of a crash, and whatever the shutdown drain could not write by shutdown-drain-ms (persistence.drain_incomplete, failures{kind=drain_lost}) — and it has no production caller yet (the dashboard's refresh event, D27, will be its first).

Retention (#310). The retention sweep's last step (AuditLogRetention, hourly, Auth §10.3, Metadata DB §8.2) deletes the rows older than the database's NOW() minus datapipelines.audit.retention-days (30–3650, default 365), in batches of at most 5,000, at most 50 batches or 2 seconds a tick. A tick that deleted anything logs one INFO line event=audit.retention purged=<n> cutoff=<iso> batches=<k>; a tick that stopped with rows left logs a WARN event=audit.retention_incomplete … reason=batch_ceiling|time_budget and the next tick continues; a metadata-DB fault logs a WARN event=audit.retention_failed purged=<n> batches=<k> cutoff=<iso> error=<class> sql_state=<state> with what it had already committed — the class and SQLState, never the store's message (§3.4G). Counts and the cutoff only — never a row's content. The meter is datapipelines.audit.retention.purged (§4.1). A step of the sweep that throws anything else logs ERROR event=retention.step_failed step=<execution_events|keys|audit_log> and the tick continues with the next step.

The audit log captures who did what when for compliance / forensic purposes. The general log captures what happened in the system for debugging.

What the audit log records, and what it does not (#164). It records authentication, administration, lifecycle verbs and MCP tool calls (mcp.tool.called, and mcp.tool.write for the mutating ones). REST reads — GET on /api/v1/* and on published endpoints — produce NO audit row, by design: an MCP key is refused on every REST route (endpoint.key_kind_refused, Auth §7.7), so an agent's reads travel over MCP, where every tool call is a row. "What did the agent see" is answered by the mcp.tool.called rows, not by REST. The one write an agent performs over REST, the visualization test screenshot upload (credentialed by the single-use DP-Upload-Token, never a key), is audited as visualization.test.screenshot_uploaded (Auth §10.1), with the run's starter as the actor.


8. Error Reporting

8.1 Uncaught exceptions

Uncaught exceptions in any thread / coroutine:

  • Logged at ERROR with full stack trace, correlation ID, and (where applicable) execution_id / node_id.
  • Counted by datapipelines.errors.total{domain} — one tag only. domain is the first segment of the error code that the failure maps to (pipeline, template, datasource, auth, result, rate_limit, idempotency; internal for a failure with no mapped code), per the {domain}.{entity}.{failure} scheme in enums §16.
  • Exception class and method are deliberately not tags: both are unbounded, code-shape-derived dimensions that §4.3 forbids. That detail is already carried by the ERROR log line's stack trace and the trace span's exception event, which are the right places to search it.
  • For pipeline-execution coroutines, fails the execution with the mapped pipeline.* code (dag-executor §8.2); a cancelled execution is execution_aborted, counted by datapipelines.executions.aborted{reason} (§4.1), not by errors.total.

A client disconnect is not an uncaught exception (#562): when the client has gone away mid-response, both Throwable backstops (ApiExceptionHandler, UiExceptionHandler) log one DEBUG line, disconnected {method} {uri}: {exception class}, with no stack trace, write no response (no envelope, no page, no toast), and it is not counted as an error. Exactly two exceptions are recognised, anywhere in the first eight links of the cause chain: AsyncRequestNotUsableException (a streamed answer whose client went away) and Tomcat's ClientAbortException (an aborted fetch). Both arise only from writing to the client's own response. A broken-pipe or connection-reset IOException is recognised only when one of them wraps it. On its own, or inside anything else, it is a failure on one of our connections (the pool, a datasource, object storage, an HTTP client), as is an EOFException: those keep the ERROR line, the stack and the 500. Every other exception that reaches the backstops keeps the ERROR line and stack above.

8.2 External error reporting (optional)

  • Sentry / Bugsnag / Rollbar integration via Spring Boot's spring-boot-starter-actuator + vendor SDK.
  • Configured per deployment (disabled by default in v1).

9. Configuration & Redaction

Inbound links land here from MCP Server §6.3; correlation-ID propagation itself is specified in §3.3.

9.1 Configuration keys

configuration.md is the single authority for config keys — YAML path, env var, default and description all live there (D8). This spec references keys by name and never restates a default. The observability keys are defined in configuration.md §3.15:

Key What it controls here
datapipelines.observability.logging.format json (§3.1) vs human-readable console (§3.5, dev). Bound at startup (#337): resolved before logging initialises, the value set is closed, and an unknown value refuses startup.
datapipelines.observability.tracing.enabled Whether the OpenTelemetry SDK and exporter are wired at all (§5). Declared and not wired — the export is #270's deferred part.
datapipelines.observability.tracing.endpoint OTLP collector endpoint (§5.3) — the standard OTEL_EXPORTER_OTLP_ENDPOINT env var

Two families deliberately have no datapipelines-namespaced key, because their frameworks already own the surface and duplicating it would create a second authority:

  • Log levels — Spring Boot's own logging.level.* (e.g. logging.level.co.datapipelines.dag=DEBUG). §3.2 states which levels carry which meaning.
  • Actuator/Prometheus exposure and path — Spring Boot's management.* (§4.2).

Sampling rates (§5.4) are set with the OpenTelemetry SDK's own otel.traces.sampler.* properties.

9.2 Redaction — normative

Redaction is not configurable and not opt-in. There is no redaction.enabled key: a switch that can turn secret-scrubbing off is a switch that will be off in some deployment. The mechanism is two layers inside the logging pipeline, so that no logger call site can bypass it — a developer cannot leak a secret by choosing the wrong logging idiom, only by inventing a key name that is not on the list. (#337 builds both on Spring Boot's structured-logging customizer point — no logstash-logback-encoder, no logback-spring.xml converter entry; the console format's one redaction word is registered by the house encoder itself; every behavioural rule of this section is unchanged.)

  1. Field filter in the JSON encoder. The house JSON members customizer applies a value processor to EVERY member the formatter writes — MDC entries, SLF4J key-value pairs, and any member added later. Any member whose key matches the sensitive-key list is emitted as "***" — the key is kept (its presence is itself diagnostic), the value never is.

  2. The rendered text, message and stack trace. The same redactor rewrites the two shapes a secret takes inside free text — key=value and "key": "value" — wherever text is rendered: on the structured format's message member, in the stack-trace printer's output (a driver's SQLException quoting the JDBC URL is the realistic one), and under the console format in the pattern's redaction word (%dpRedact, a house converter over the rendered message, correlation fields and exception word). Both formats call the SAME single recognizer (RedactionScanner, reached through LogRedactor.scrubText), so they cannot drift. Recognition is one left-to-right pass over the ORIGINAL text, and overlapping spans merge: every key start is tried against the original characters, spans that overlap become one region, and the region is replaced once, by the replacement of the span that started it — so a secret-shaped fragment INSIDE a value ({"password":"x secret=abc\"tail"}, password="{\"api_key\":\"…\"}") is part of that value and can never end the outer value early, and a nested match whose own value runs past the end of the one holding it ("secret": secret= tail) extends the mask to its end. Nothing is rewritten until the pass is over. The work is linear in the text length and a failed match is never rescanned (a repeated password_password_… token with no value costs the same per character as any other text); the tests hold it to a counted number of scan steps rather than a timing. The shapes are matched case-insensitively in both forms ({"PASSWORD":"…"} is masked like password=…), by the same key rule, and the WHOLE value is masked with its delimiters kept: a quoted value (double- or single-quoted) runs to its first UNESCAPED closing quote, so an escaped quote or backslash inside it (\", \\) never ends it; a quote never closed masks to the end of its line only; a bare token ends at whitespace or a delimiter; the JSON form also masks a bare scalar ("secret":12345). The stack trace is redacted as a whole before its 8192-character output bound is applied, so a value the bound would have cut is already masked.

    Known limits of the text layer (the field filter in layer 1 has none of them — a member is judged by its key): a value that is an object or array under a sensitive key (its own members are matched by their own keys); a JSON key whose quotes are themselves escaped (\"password\": — text that was JSON-encoded before it was logged); Python-style single-quoted keys ('password': 'x'); the unquoted colon form (password: x); and a secret under a key that is not on the list. Text matching over-matches on purpose — dbPassword= and "client-secret" are masked though they are not *_<key> compounds — and the member matcher is boundary-strict (db_password_v2 matches, dbpassword does not).

Both layers read the same sensitive-key list (case-insensitive, matched on the whole key and on *_<key> / <key>_* compounds):

Key Why
password Datasource credentials, OIDC client secrets
secret JWT secret, OIDC client-secret
api_key dpk_... plaintext key, only ever legitimately returned once at creation
authorization Bearer / DP-API-Key header values
jdbc_url Carries credentials inline for several drivers, and leaks internal topology even when it does not
encryption_key DATAPIPELINES_DB_ENCRYPTION_KEY

Never redacted, by design: correlation_id, execution_id, pipeline_id, node_id, datasource_name, user_id. These are the fields that make an incident diagnosable; scrubbing them would defeat the purpose of structured logging.

Row values are never logged at all — not at INFO, and at TRACE only under the per-row diagnostic logger that §3.2 marks as disabled in production. Redaction is a backstop for accidents, not a licence to log result data.

9.3 Redaction beyond logs — error payloads

The same secrecy rule binds anything that leaves the process, not just log output. Normative: an error details map must never carry jdbc_url, a password, or a raw connection string — in an SSE event payload, in a REST error envelope, or in an MCP tool result. Failures that are about a datasource identify it by datasource_name and, where useful, an underlying_error string that has itself passed the redaction list.

REST API §6.4.4 node_failed already complies: its details carries datasource_name + underlying_error and no URL. The rule is stated here so the next event type added does not have to rediscover it.

This is a construction rule, not a filter — the redacting encoder covers logs, and error envelopes are built by hand, so the two must be kept honest independently.


10. What's Out of Scope for v1

  • Distributed tracing across pipelines (e.g., tracing a pipeline execution that triggers another pipeline) — v1 has no pipeline-to-pipeline calls, so not needed.
  • Custom dashboards — operator responsibility (Grafana templates provided as documentation, not shipped code).
  • Per-tenant metrics — single-tenant v1.
  • Real-time alerting — operator responsibility (Alertmanager / equivalent).

Appendix A: Change Log

Date Version Author Change
2026-11-01 v1.47 762 (#762) never-admissible dashboard refresh §3.4J gains dashboard.refresh_never_admissible: the WARN a refresh logs when it is refused at once because it can never be admitted on this instance (workspace_id, dashboard_id, invocations, ceiling, key).
2026-10-24 v1.46 442b (#442) schedule notification delivery §3.4E gains the nine scheduler.notification.* events (enqueued, backlog_full, sent, retry, failed, uncertain, render_failed, port_failed, settle_lost) and the rule that they carry the recipient's domain, never an address; §4.1 gains datapipelines.scheduler.notifications.{sent,failed,uncertain}.
2026-10-21 v1.45 #637 retention event-catalogue parity §3.4's namespace sentence now lists thirteen namespaces, including retention; ObservabilityEventCatalogParityTest enforces the existing retention.step_failed row with a non-vacuity floor of one. No event added or renamed.
2026-10-21 v1.44 636 (#636) the housekeeping statements' bounds §3.4J dashboard.refresh_sweep_failed and dashboard.refresh_retention_failed, and §3.4M parameter.evaluation_sweep_failed and parameter.evaluation_retention_failed, gain "or was cancelled at its bound (sql_state=57014)" with their bound (the refresh sweep's 10 s, the retention tick's 25 s) and their tick_failed job; §4.1 tick_failed lists eight job values; §3.4I retention.step_failed counts every step under its job, only what escaped the step's own catch; §3.4H names the dashboard refresh sweep among the dp-scheduled jobs and the four bounds in shutdown.scheduled_jobs_incomplete (the pool reaper is the one unbounded job left). No event added or renamed.
2026-10-20 v1.43 326 (#326) the scheduled jobs' statement bounds §3.4I execution.sweep_failed and execution.event_retention_failed, and §3.4N audit.retention_failed, gain "or was cancelled at its bound (sql_state=57014)"; §3.4I gains the retention.step_failed row, cited by §3.4H and §7 but defined nowhere until now; §3.4H's shutdown.scheduled_jobs_incomplete row says the bounds sit under the 30 s shutdown phase and names the steps that are not bounded. §4.1 gains datapipelines.scheduled_jobs.tick_failed (counter, tag job from a closed set of four). The bounds are code constants (sweep 10 s, retention 25 s), not keys. No log line's fields change.
2026-10-20 v1.42 #569 The concurrency gauge is bound by the execution-slot bean, once per instance. Executor construction does not register it; rebinding the same pool is a no-op and a different pool fails fast.
2026-10-17 v1.41 562 (#562) a client disconnect is not an uncaught exception §8.1 gains the rule: a client that went away mid-response is recognised at both Throwable backstops — one DEBUG line with method, URI and class, no stack trace, no response written, not counted. Exactly two exceptions count, in the cause chain: AsyncRequestNotUsableException and ClientAbortException. A broken-pipe or connection-reset IOException counts only inside one of them; any other I/O failure, EOFException included, keeps its ERROR line and 500. Until now each disconnect logged 500 … unhandled … at ERROR with a stack for a 500 no client could receive. Every other exception's ERROR line is unchanged.
2026-10-08 v1.40 #443 event-catalogue parity §3.4 adds 37 rows to complete the seven remaining catalogued namespaces; all twelve are enforced with count floors. Audit retention and execution shutdown receive additive §3.4N/O tables. Computed event fields are refused except the two exact, count-pinned legacy audit-row formats; production logs and §3.4G FailureShape/SQLSTATE prose are unchanged.
2026-10-06 v1.39 #446 bounded driver SQLSTATE (2026-10-06T03:09:15Z) §3.4G defines the one-to-five ASCII letter/digit shape, fixed invalid replacement and preserved none fallback. Valid states pass unchanged; rejected driver values are never logged.
2026-10-04 v1.38 329a (#329), store-failure log shapes §3.4C lake outcome-recorder field list gains sql_state and defines error as the exception class; §3.4G records the narrow reuse of its rule at four existing failure lines. Event names and failure behavior stay unchanged; the remaining inventory stays open on #329.
2026-10-03 v1.37 #403 failed pre-start release §3.4I gains execution.idempotency_release_failed: one WARN per failed cleanup attempt, user/execution ids and error class only; the original refusal is preserved.
2026-10-03 v1.36 439 (#439) an event-catalogue parity guard §3.4's intro states that ObservabilityEventCatalogParityTest fails the build when an event=<namespace>.<name> literal in modules/<module>/src/main/kotlin and the event column of these tables disagree for the namespaces whose tables are already complete (parameter, lake, mail, persistence, scheduler); its non-vacuity floor pins each namespace's count, and the catalogued namespaces still drifting are tracked in #443. No event row changes.
2026-10-03 v1.35 393 (#393) a throwing metrics hook is contained §3.4G gains persistence.hook_failed (WARN; writer, hook, cause — the hook's method name and the exception's simple class name, never its message): every BatchingHooks call in BatchingWriter goes through one guarded call, so a hook that throws can no longer strand a claimed entry, make commit re-write a batch the store already holds, or end a writer thread. The item's outcome stands; the hook still runs before the caller's release (#363). At most once per 10 s per writer, on the interval persistence.saturated uses.
2026-10-03 v1.34 429 (#429) the two parameter.* failure lines follow the class-and-SQLState rule — renumbered at merge after 425's v1.33 §3.4M: parameter.evaluation_failed no longer attaches the throwable (its message and stack could carry SQL or a value) — it names error (binary class name) and sql_state, and the stack moves to ONE new DEBUG row, parameter.evaluation_failed_cause (evaluation_id); parameter.evaluation_record_failed spells an absent SQLState none (FailureShape's spelling), where it logged the string null.
2026-10-03 v1.33 425 (#425, #385) the selector abandon event namespaced and worded §3.4M gains its 19th row: parameter.selector_statement_abandoned (the bare event=selector_statement_abandoned of SelectorPool.abandon, which the audit's namespaced extraction could not catalogue). The line gains cause (deadline when the cancellation is the evaluate's own withTimeout, caller otherwise — #375's disconnect-grace abort, a shutdown) and no longer says "the evaluate's deadline passed" for a cancellation that was not one; it stays ONE error line with no throwable and no SQL or bind.
2026-10-03 v1.32 418 (#418) the parameter evaluation events catalogued New §3.4M: the 18 event=parameter.* names the code logs (#376's history writes, the sweep and retention, the observed stream and its grace), each with its level, trigger and fields; a failed history write is ERROR and the evaluation continues unrecorded. The docs audit's §3.4 event extraction admits the parameter namespace in the same commit (scripts/docs-audit.sh; the execution/dashboard precedent) — without it every backticked name fails check C against the error-code catalog. metadata-db §8.1/§8.5 cite the rows.
2026-10-02 v1.31 235 (#164) the audit boundary stated §7 states the rule: the audit log records authentication, administration, lifecycle verbs and MCP tool calls; REST reads produce no audit row by design (an MCP key is refused on REST); the one agent write over REST, the screenshot upload, is audited as visualization.test.screenshot_uploaded.
2026-09-30 v1.30 337-c (#337) redaction composed on the original text §9.2: the rendered-text layer is ONE left-to-right recognizer over the original text, shared by the JSON message/stack_trace members and the console format — not an assignment pass followed by a JSON pass (and, on the console, two nested %replace calls). The delivered composition let the first pass rewrite text inside a quoted value (consuming an escape with it), so the second pass read an orphaned quote as the end of the outer value and a secret tail survived ({"password":"x secret=abc\"tail"}); a secret-shaped fragment inside a value is now part of that value, and overlapping spans merge into one masked region (a nested match that outruns its container extends the mask). A failed match is no longer rescanned from every later key start (a repeated password_… token with no value was quadratic, measured 4 s at 72 KB); the bound is a counted-step test. The console pattern's redaction word %dpRedact is a house logback converter registered by the encoder. The sensitive-key list, the never-redacted set, ***-keeps-key, the redact-before-bound order and the no-disable rule are unchanged (drift-tested); the known limits are unchanged.
2026-09-30 v1.29 337-b (#337) redaction and console correlation completed §9.2: the rendered-text layer is now stated as built — case-insensitive in BOTH text forms by one key rule (the JSON form was case-sensitive), the whole value masked with delimiters kept (escaped quotes and backslashes never end a quoted value; single-quoted values; bare JSON scalars; a never-closed quote masks to the end of its line), the stack trace redacted BEFORE its 8192-character bound — and its known limits are listed rather than implied (object/array values, escaped-quote JSON keys, Python-style keys, the unquoted colon form). The member matcher now also accepts the prefixed-and-suffixed compound the text matcher always masked (db_password_v2). §3.3: the console format prints correlation_id= / execution_id= before the message (- when absent; the two named MDC keys, no MDC dump). §3.1: under json the binding switches Spring's banner off so a normal startup's stdout is JSON lines only (pinned by a normal-boot test). The scrubber's key pattern lost its prefix group loop: measured on the delivered pattern, a 4 KB a_a_a_… token overflowed the stack inside the logging call and a 50 KB word before = took ~15 s. The sensitive-key list, the never-redacted set, ***-keeps-key and the no-disable rule are unchanged (drift-tested).
2026-09-30 v1.28 337 (#337) structured local logging §3.1 shipped: the JSON line is real — Boot 3.5's structured logstash format with a house redacting members customizer, logger_name/thread_name renamed to the standard fields, MDC and key-value pairs flattened at the root; §3.1's mechanism sentence no longer names logstash-logback-encoder (the D1 deviation, no new dependency; every behavioural rule kept). §3.3 shipped: the propagation bullets name what was built — a request-built ThreadContextElement (correlation_id + execution_id) over the execution's coroutines and per-pool submitter-MDC capture — replacing "Spring's TaskDecorator" (the D3 mechanism deviation), plus the normative NOT-propagated list (batching writer and drain, the schedulers, the mail worker). §9.2's mechanism sentences amended to the built two layers: the members value processor and the rendered-text scrub (message member, stack-trace printer, console %replace) — the record's logstash-logback-encoder field filter and logback-spring.xml MessageConverter are gone, the list and the never-redacted set unchanged and drift-tested against the code. §9.1: the logging-format row states the binding (resolved before logging initialises, closed value set, unknown refuses startup) and the tracing.enabled row says declared-and-not-wired; the §3.14 anchor text corrected to §3.15. §3.5: local dev's console format is the switch's console value, not a logback-spring.xml profile. logging.format values: json prod default (compose passes it), console dev (defaults.env), both now actually bound.
2026-09-30 v1.27 lane 336 (#336 D7) New §3.4K the live-write outage events: execution.progress_write_failed (WARN, once per execution per outage — §3.2's rule at heartbeat cadence) and execution.progress_write_recovered (INFO), named by class and SQLState through FailureShape. §4.1 gains datapipelines.executions.progress_write_failed (counter, no tags): a live heartbeat/progress write refused — the state that makes a LIVE execution look dead to another instance's stale sweep. Renumbered at merge: L2 retains §3.4J. New §3.4L: the selector-lease cleanup WARN datasource.lease_cleanup_failed (operation-named, class + SQLState — a refused connection close discards the connection).
2026-09-29 v1.26 L2 (#10) the dashboard runtime New §3.4J — the refresh events (dashboard.refresh_finished, the failure lines by class and SQLState only, the stream cut / grace lines, the sweep and retention lines) — and five §4.1 rows: datapipelines.dashboard.refreshes (by terminal status), …refresh.duration, …refresh.refused (saturated / stream_limit), and the two gauges …refreshes.active and …executions.reserved.
2026-09-29 v1.25 321 (#321) the scheduled jobs' failure lines — renumbered at merge: 306/316 and their reviews took v1.21–v1.24 §3.4's dag row states the rule for the crash sweep's and the event retention's failure lines: a failed tick logs error=<class> sql_state=<state> where it logged the store's message (FailureShape, the §3.4G rule; dag gains the persistence edge for it, module-structure §4.2). The event names are not cited here: on this base the docs audit reads execution.* as a permission family (#307), and the events' own table arrives with the 316 merge (§3.4I). §7's retention paragraph said audit.retention_failed … message=…; the line has logged error=/sql_state= since #310 — corrected.
2026-09-29 v1.24 the 316 merge's review (#316) New §3.4I: execution.events_purged catalogued — §3.4H cited it and the docs audit's citation check (#307, on main since 4b91c387; not on the lane's base) refused the uncatalogued name; #321 adds the two failure rows.
2026-09-29 v1.23 316 (#316) the scheduled jobs' own thread — renumbered at merge: 306 took v1.21 and its pass v1.22 New §3.4H: every @Scheduled job runs on dp-scheduled, the jobs' own scheduler, so their lines (named there) carry that thread — until #316 they carried dp-sse-log, the SSE log streamer's; two shutdown lines, shutdown.scheduled_jobs_stopped (INFO, after both drains) and shutdown.scheduled_jobs_incomplete (WARN, a tick outlived the shutdown wait).
2026-09-29 v1.22 the 306 merge's security pass (#311) §4.1: lifecycle_write_failed also counts a terminal UPDATE that matched no row (#325); the emitter's lifecycle failure lines log class + SQLState at WARN (§3.4G), never the driver's message.
2026-09-29 v1.21 306/#311 (the execution row's order and bounds) — numbered after 310's v1.20 §4.1 gains datapipelines.executions.lifecycle_write_failed (counter, no tags): the terminal UPDATE of pipeline_executions failed or outlived its bound (datapipelines.executor.lifecycle-write-timeout-seconds), the row is left RUNNING for the stale sweep, and the counter is the difference between that and a dead instance. The RUNNING insert's failure is deliberately uncounted here (fail-closed for scheduled runs, WARN for interactive).
2026-09-29 v1.20 310 (#310) audit-log retention — numbered after 297's v1.18 and 266b's v1.19 §4.1 gains datapipelines.audit.retention.purged (counter, no tags). §7 gains the retention paragraph: the job, its three log events (audit.retention INFO, audit.retention_incomplete and audit.retention_failed WARN), the sweep's per-step retention.step_failed ERROR, and the meter — the "retention governed by" line was true of the documentation only until #310
2026-09-29 v1.19 266 (#266) persistence batching, with its correction round 266b — renumbered from v1.18, which 297 takes New §3.4G the persistence events (persistence.batch_retried, write_failed, direct_write_failed, indeterminate, direct_write_abandoned, saturated — at most once per 10 s — writer_died, drained, drain_incomplete, and the two shutdown.persistence_* lines); §4.1 gains the persistence batching table (datapipelines.persistence.*: batch size and duration, lag, queue depth and bytes, failures by kind, fallbacks by reason, retried batches, dropped submissions — every one tagged store); §7 states the delivery rule and the loss window (only the non-awaited submit path, no caller yet) and marks audit retention as documented-not-implemented. 266b: a failure is logged by cause (class) and sql_state, never by the store's message, which quotes the refused row; the audit writer ships off (audit.enabled: false), so audit.write reads direct by default and the audit meters read zero.
2026-09-28 v1.18 297 (#292) build time opt-in §6.3's build_time becomes absent when not supplied, like commit: the build stamps a time only with -Pdatapipelines.buildTime=<instant> (app.sh's image build passes it), because the plugin's default Instant.now() made the app's jar new on every Gradle invocation and re-ran its tests and the whole browser suite each time.
2026-09-28 v1.17 298 (#293) the log-served streams' cut §4.1's datapipelines.sse.stream.duration row: the timer covers the live stream; the log-served streams (replay, idempotent-retry follow) record no duration and tag their cut's log line close_reason=expired or close_reason=revoked from the same verdict that refused (#271's one-judgement rule) — before, every log-served cut read as a revocation.
2026-09-28 v1.16 286 (#286) New §3.4F the legacy endpoint events: endpoint.legacy_rows (#274's once-per-JVM WARN, its field now at_least — the first query's count is a floor) and endpoint.promotion_legacy_omitted (#286 — a promotion batch left legacy rows out; before #274 the batch threw, after it the omission was silent). Both carry a count, never a path.
2026-09-26 v1.15 262 (#263) §4.1 datapipelines.sse.stream.duration's close_reason closed set gains expired: the same policy cut as revoked — the subscriber's re-judgement refuses a write, the final comment is the same static string, the execution keeps running — where the refusal was the subscriber's validated token passing its exp (#263), so an expired token is never counted as a standing revocation or a client_disconnect.
2026-09-26 v1.14 230 (#230, security-assurance P4) §4.2 datapipelines.sse.stream.duration's close_reason closed set gains revoked: a stream cut by the subscriber-authority re-judgement before a write. Recorded BEFORE the terminal question — a cut stream has no terminal event of its own, and counting it as client_disconnect would feed D7's cancellation story a reader it never had; the execution deliberately keeps running (P4's first half).
2026-09-25 v1.13 scheduler lane 1 (#9) New §3.4E the scheduler events (scheduler.started / api_mode / dispatched / reconciled / run_not_started / schedule_blocked / start_failed / inspect_refused / admission_closed / admission_wait_expired) and four scheduler metrics in §4.1 (occurrences{outcome}, runs.finished{state}, capacity.retries, runs.in_flight). §6.1: no scheduler health component — db-scheduler's indicator is disabled. docs-audit.sh check C does not extract scheduler.* yet (#252; the same gap v1.7 names for lake.*).
2026-09-17 v1.12 158 (#121) mail connect retry §3.4B gains mail.send_retry (WARN, kind + attempt + error): a connect-failed notice is retried in place (bounded, 3 attempts) before the claim row is marked — the retry line is per attempt, mail.send_failed remains the terminal one.
2026-09-17 v1.11 152 R152-8 retained-owner containment (#128) lake.instance_owner_close_failed now covers a nonfatal RuntimeException too (v1.10's catch was SQLException-only, and the escape crossed into the shared manager's pool loop); lake.instance_closed gains retained_owner_closed so a refused physical close is never reported as a closure.
2026-09-17 v1.10 152 R152-5/6/7 ownership protocol (#128) §3.4C gains lake.instance_handle_exhausted (WARN — the counted residual: every permitted close refused, no actor left) and lake.instance_refused_duplicate_close_failed (DEBUG — a duplicate refused at registration whose creator's close failed; still owned). lake.instance_handles_closed's in_flight is now true by construction (the hand-off is decided in the closer's own failure transition).
2026-09-17 v1.9 152 R152-3/4 confirmed closure (#128) lake.instance_handles_closed now reports closed / already_closed / in_flight / close_failures separately — a handle leaves the generation's registry only when the driver has CONFIRMED its close, so the counts say what actually closed, never what was merely attempted.
2026-09-17 v1.8 152 R152-2 handle accounting (#128) §3.4C gains lake.instance_handles_closed (WARN, handles + close_failures): a generation now closes, at its own release, every physical connection it created that HikariCP never accepted or abandoned — a non-zero count is the rare shutdown race, not a leak.
2026-09-16 v1.7 152 LAKE instance events (#128) New §3.4C the LAKE instance events: lake.instance_opened / lake.instance_closed (one per pool generation), the ERROR lake.instance_owner_lost (a live pool's engine went away; leases refused with 08003 until rebuild), the WARNs lake.instance_owner_close_failed, lake.view_failed (109 §A, previously undocumented) and lake.view_outcome_record_failed. docs-audit.sh check C does not yet extract the lake.* namespace (its alternation is scripts/, outside lane 152's fence) — a follow-up.
2026-09-14 v1.6 137 mail notices New §3.4B the mail events: mail.configured / mail.disabled at boot, mail.skipped (mail off), mail.already_claimed, and the WARNs mail.send_failed / mail.dispatch_failed; domains and counts only, never an address or a body. docs-audit.sh check C now extracts mail.* log events from this section and mail.* audit events from Enums §15, so a misspelt name fails the audit.
2026-08-05 v1.0 draft initial draft Initial observability spec sketch — logs, metrics, traces, health, audit log
2026-08-07 v1.1 draft consistency campaign Applied SPEC-REVIEW-2026-08 §2.14. [M] §6 health payload/paths realigned to the canonical rest-api §11.1 — root-level /health,/ready,/info, top-level version, snake_case components {database, redis, h2_factory}, diskSpace removed with rationale. [M] Stale metrics purged: auth.login.attempts{outcome=locked} dropped (OIDC-only, no local passwords, no lockout) with outcomes remapped to the auth §10.1 audit events; datapipelines.http.server.requests → http.server.requests (Spring Boot's own unprefixed metric) plus a rule on which metrics keep framework names. [D9] Result/SSE/idempotency metrics added: result.bytes_written, result.writes{outcome}, result.cursor.reads{format,outcome}, result.expiries, result.size, sse.streams.active, sse.stream.duration{close_reason}, idempotency.cache.hits, idempotency.conflicts. [D7] executions.aborted{reason} registered (matches dag-executor §15.3). [M] §8.1 errors.total{class, method} → {domain}, with §4.3 gaining the normative closed-set tag rule that forbids code-shape tags. [M] §9 rewritten as Configuration & Redaction: local YAML block deleted (config keys now referenced from configuration.md §3.14 per D8), redaction respecified as a non-optional two-layer mechanism (JSON-encoder field filter + MessageConverter) over an explicit sensitive-key list, plus §9.3 forbidding jdbc_url/credentials in error details across SSE, REST and MCP. [D10] X-Correlation-Id → DP-Correlation-Id. [M] Correlation propagation past the HTTP boundary made normative — echoed in every SSE event payload, _meta on MCP results (mcp-server §6.3). [M] Cross-ref fixed: audit log → auth §10 (was §9). Draft status kept honest: dashboards/alerting/SLOs still to be elaborated, but §3.3, §4.1/§4.3, §6 and §9.2 are marked normative (§1).

This is the documentation packaged with the running version. The same files live in the repository on GitHub.