Skip to content

feat(executor): end-to-end latency instrumentation with correlation ids - #2361

Open
thesithunyein wants to merge 7 commits into
KeeperHub:stagingfrom
thesithunyein:feat/executor-latency-instrumentation
Open

feat(executor): end-to-end latency instrumentation with correlation ids#2361
thesithunyein wants to merge 7 commits into
KeeperHub:stagingfrom
thesithunyein:feat/executor-latency-instrumentation

Conversation

@thesithunyein

@thesithunyein thesithunyein commented Sep 8, 2026

Copy link
Copy Markdown

What

End-to-end execution latency instrumentation for the full pipeline the issue
describes — event-tracker → SQS → executor → runner/broadcast. Every event
trigger now gets a correlation id minted at the moment it is first observed
and carried through every stage, with per-stage timestamps and latency
histograms
:

observed → received → started → dispatched → broadcast → completed

Why

Latency today is only observable per-workflow from inside the engine
(workflow.execution.duration_ms). Nothing distinguishes producer → queue →
executor
delay from executor → runner delay, and there is no key that joins
an event's tracker-stage logs to its SQS/executor/runner-stage logs. This
implements #2289's ask directly: a correlation id and stage timestamps from the
event-tracker through the executor, plus histograms, so a slow producer, a slow
queue and a slow runner are each visible independently — and a single run can be
traced across three systems on one key.

What changed (2 commits, 13 files, +614/−13)

Commit 1 — executor stage (1d53470):

File Change
keeperhub-executor/latency.ts (new) ExecutionLatency stage tracker — idempotent marks (first wins), derived durations, JSON-safe log fields, CSPRNG generateCorrelationId() (16 hex, no new deps)
keeperhub-executor/index.ts Correlation id reused/minted at receive; dispatchExecution marks dispatched, emits the receive→dispatch histogram and a structured [Executor:Latency] summary line (skipped for in-process, which records its own full-timeline line)
keeperhub-executor/in-process.ts Marks started/completed around the engine call; receive→started + receive→completed histograms; correlation id on Completed/Fatal logs
keeperhub-executor/k8s-job.ts KH_CORRELATION_ID env var + correlation-id pod label
lib/metrics/types.ts executor.dispatch.latency_ms, executor.execution.latency_ms; correlation_id, dispatch_target labels

Commit 2 — tracker + runner legs (4e6330e):

File Change
keeperhub-events/event-tracker/lib/correlation.ts (new) Shared generateCorrelationId() (same format as the executor's)
.../lib/workflow-sqs.ts Optional correlationId/observedAt carried on the SQS message; absent for legacy callers (undefined is dropped by JSON.stringify)
.../src/listener/event-listener.ts Id + observedAt minted at the moment the event is first observed; observed <tx> correlationId=… log line
keeperhub-executor/types.ts + message-schema.ts Optional correlationId/observedAt on event messages; legacy messages without them still validate (drift guard kept)
keeperhub-executor/workflow-runner.ts Emits KH_CORRELATION_ID on start, completion and fatal logs so the pod joins the same trace key

Design notes

  • First mark wins — a recovered/redelivered path can never overwrite the
    first observation, keeping the histograms honest.
  • Failure never fabricates latencycompleted is only marked when a
    terminal status lands; a crash shows as a missing series, not a fast fake
    reading. Error paths still carry the correlation id.
  • Backward compatible — all new message fields are optional and the tracker
    omits them for legacy callers, so older producers/messages behave exactly as
    before (the executor falls back to minting its own id).
  • No new dependenciesnode:crypto only.
  • Emission points are single — in-process runs record their own full
    timeline; handed-off targets (k8s-job/api) record the receive→dispatch
    handoff. No double counts.

Testing

  • 20 new unit tests (13 executor latency + schema, 5 executor observed
    stage/queue leg/ordering, 2 tracker SQS payload carry/legacy omission)
  • Executor suite: 143 tests passing
  • Event-tracker unit suite: 222 tests passing
  • tsc --noEmit clean for both packages (touched files)

Follow-up (deliberately out of scope)

  • Dashboard/alert wiring on the new histograms.
  • Scheduler (cron/block) producers could carry the same id/observedAt — the
    executor already handles it whenever a message carries the fields.

Verification for reviewers

pnpm install
npx vitest run keeperhub-executor/latency.test.ts
npx vitest run keeperhub-executor
cd keeperhub-events/event-tracker && npx vitest run tests/unit

Sample emitted line (tracker):

[EventListener:abc123] observed 0xdead… correlationId=1a2b3c4d5e6f7890

Sample emitted line (executor, in-process run):

[Executor:Latency] correlationId=1a2b3c4d5e6f7890 workflowId=wf-1 executionId=exec-1 triggerType=event dispatchTarget=in-process observedAt=… receivedAt=… startedAt=… completedAt=… queueToStartMs=50 totalMs=200

…ds (KeeperHub#2289)

Mint a correlation id at SQS receive and thread it through dispatch to the
runner pod and the in-process engine, recording per-stage timestamps:

  received -> started -> dispatched -> broadcast -> completed

- latency.ts: ExecutionLatency stage tracker (idempotent marks, derived
  durations, JSON-safe log fields) + CSPRNG correlation ids
- processMessage/processExecutorMessage: correlation id minted at the
  earliest receipt point; dispatch emission of the receive->dispatch
  histogram and structured [Executor:Latency] summary line
- executeInProcess: started/completed marks around the engine call,
  receive->started + receive->completed histograms, correlation id on
  existing completed/fatal logs
- createWorkflowJob: KH_CORRELATION_ID env + pod label so runner logs
  join the same trace key
- metrics types: executor.dispatch.latency_ms / executor.execution.latency_ms
  histograms + correlation_id / dispatch_target labels

Tests: 13 new unit tests for id/order/idempotency/duration/serialization
and the metric constants. Executor suite: 138 passing.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

About the build check on this pull request

This pull request comes from a fork, so GitHub does not pass it the credentials build normally uses for our image registry cache and staging build configuration. The build still runs and still compiles the image, so a red build here is real; it just takes longer than on team branches.

Every workflow run on a pull request from a fork also waits for a maintainer to approve it, so checks can sit at "awaiting approval" for a while after each push. Nothing is needed from you for either of these.

…tracker -> SQS -> executor -> runner)

Adds the event-tracker leg of the latency instrumentation, so the correlation
id now spans the full pipeline the issue describes:

- event-tracker: generateCorrelationId helper; mint the id + observedAt at
  the moment an event is first observed (EventListener.onLog) and carry both
  on the SQS message (workflow-sqs.ts, omitted for legacy callers)
- executor: reuse the tracker-minted id and observed stage when the message
  carries one (falls back to minting at receive); event schema accepts the
  optional fields while legacy messages still validate
- runner: emit KH_CORRELATION_ID on start, completion and fatal logs so the
  pod joins the same trace key
- latency: new 'observed' stage (tracker observation -> receive queue leg)

Tests: +2 tracker unit (payload carry/legacy omission), +5 executor unit
(observed stage, queue leg, summary ordering, schema acceptance/rejection).
Executor suite 143 passing; event-tracker unit suite 222 passing; tsc clean.

@joelorzet joelorzet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metrics half of this is dead. It will not be merged in this state.

piece state
executor.dispatch.latency_ms name only, no histogram, nothing recorded
executor.execution.latency_ms name only, no histogram, nothing recorded
broadcast stage declared, never marked

Every sample is discarded and logs Unknown latency metric. A declaration nothing implements is not a smaller version of the feature. It is a feature that does not exist while looking like it does.

Please finish all three here. We are not landing the shape now and the implementation later.

The correlation id half is done and worth keeping. The tracker mints the id and puts correlationId and observedAt on the SQS message, the executor reads them and mints its own when they are absent, and k8s-job.ts injects KH_CORRELATION_ID plus a correlation-id label so the runner pod joins on the same key. One run is traceable across three services, and each stage is recorded once so a retry cannot overwrite the first observation.

Four things inline.

Smaller: keeperhub-events/event-tracker/lib/correlation.ts has no trailing newline, and it duplicates generateCorrelationId. If the package boundary forces the copy, say so in the comment the way in-flight.ts does.

Comment thread lib/metrics/types.ts
// and the full receive -> terminal lifetime. Split by trigger + dispatch
// target so a slow producer, a slow queue, or a slow runner is visible
// independently.
EXECUTOR_DISPATCH_LATENCY: "executor.dispatch.latency_ms",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a name here does not create a metric. Neither of these reaches Prometheus.

recordLatency resolves the name against a fixed map and drops anything it does not know:

const histogram = histogramMap[name];
if (histogram) {
  histogram.observe(sanitizeLabels(labels), durationMs);
} else {
  logWarn(`[Prometheus] Unknown latency metric: ${name}`);
}

histogramMap in lib/metrics/collectors/prometheus.ts holds four latency histograms and neither of these is among them. This PR does not touch that file, and the executor imports the same collector, so every sample is discarded and each execution writes a warning instead.

Register both there with a help string and buckets, and add a test asserting a sample lands. Nothing here would have caught this.

One thing to address while you are in that file. It carries a note saying workflow execution and step metrics deliberately moved to DB-sourced gauges, and dbSourcedMetrics holds workflow.execution.duration_ms and workflow.step.duration_ms. Your two measure queue time and dispatch hand-off, which the database has no timestamps for, so runtime histograms are the right choice. Say that in the comment, otherwise the next reader sees you going against a decision recorded a few lines above.

Comment thread lib/metrics/types.ts Outdated
MODE: "mode",
CLAIM_RESULT: "claim_result",
// Latency instrumentation (issue #2289)
CORRELATION_ID: "correlation_id",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep this out of the histogram labels when you register them.

A correlation id is a fresh value per execution, so it creates one time series per execution. #2289 rules this out by name: "per-workflow labels on a latency histogram are a metrics-cost problem", and this is finer-grained than per-workflow. The comment in latency.ts calling the id "short enough for labels" points the wrong way.

The id belongs in the structured log, where it already does its job. Label the histograms with trigger, dispatch_target and stage.

| "received"
| "started"
| "dispatched"
| "broadcast"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

broadcast is declared here and in STAGE_ORDER, and nothing marks it. Across the whole diff the marks are observed, received, started, dispatched and completed. The only edit inside the pod that broadcasts, in workflow-runner.ts, appends the correlation id to two log lines and records no timestamp.

That is the measurement the issue exists for. #2289 lists "Transaction broadcast" as a required stage, and asks for the distribution of time from event observed to transaction broadcast, and which stage dominated it. observed is recorded and broadcast is not, so that interval cannot be computed and the per-stage attribution stops one hop short.

Mark it where the transaction actually goes to the chain.

Comment thread keeperhub-executor/latency.ts Outdated
* Single-line structured summary matching the executor's JSON log shape
* (`[Component] key=value ...`). Parsable with a plain key=value splitter.
*/
summaryLine(params: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use logInfo for these lines rather than building the summary by hand.

lib/logging.ts exports logInfo(message, labels), which emits the canonical structured line with the labels already in the shape the log pipeline parses. This builds a second, parallel format to carry the same fields, and getting the correlation id into the logs is the point of the PR.

Scope it to the new latency lines only. The surrounding console.log calls in the executor are the existing convention there and are not yours to change.

One thing to watch: lib/logging.ts imports @sentry/nextjs. You already import lib/metrics, so most of the chain is present, but if that import breaks the executor bundle, say so and keep console.log with a note explaining why.

@joelorzet joelorzet added the changes-requested Triage: reviewed, changes needed from the contributor label Sep 8, 2026
Addresses all four review comments on this PR:

1. Register the executor latency histograms in histogramMap. The two
   declared names never reached Prometheus because recordLatency resolves
   against a fixed map; every sample was discarded with 'Unknown latency
   metric'. Now registered in apiRegistry with help strings and buckets,
   plus a comment explaining why these are runtime histograms despite the
   DB-sourced-gauges note (queue/hand-off intervals leave no DB timestamps).
   A regression test asserts a sample lands in the exposition.

2. Remove the correlation id from histogram labels. A fresh id per
   execution is one time series per run (KeeperHub#2289 rules out even per-workflow
   labels). LabelKeys.CORRELATION_ID is gone; histograms are labeled
   trigger_type / dispatch_target / stage; the wrong-way comment in
   latency.ts is rewritten.

3. Mark the broadcast stage where the transaction actually goes to the
   chain: both EVM adapter broadcast points (sign-once failover and legacy
   signer.sendTransaction), the Solana submit point, and the Turnkey
   sponsored path. The write paths cannot reach the ExecutionLatency
   instance, so the timestamp travels via a /tmp sidecar plus a
   process-local counter that ships with the counter deltas. In-process
   runs read the marker back after the engine returns and record the
   observed -> broadcast distribution (executor.broadcast.latency_ms);
   k8s-job runs log the marker joined on correlation id, since ephemeral
   pods do not ship histogram observations.

4. Replace the hand-built summary lines with logInfo(message, labels)
   from lib/logging, scoped to the latency lines only. The executor
   already imports lib/logging transitively, so the Sentry chain adds
   nothing new. emitLog lives on ExecutionLatency; tests assert the
   canonical structured shape.

Also: logInfo import verified bundle-safe for the executor; correlation.ts
trailing newline + duplication note added per review; METRICS_REFERENCE
rows added for all three histograms and the broadcast counter.

New/updated tests: 152 executor + 7 web3/metrics, tsc clean.
@thesithunyein

Copy link
Copy Markdown
Author

Thanks for the precise review — all four points were correct, and all four are fixed in edc6193. The metrics half is real now.

1. Histograms registered (lib/metrics/collectors/prometheus.ts)

Both names are now in histogramMap, registered on apiRegistry with help strings and buckets:

  • executor.dispatch.latency_ms -> keeperhub_executor_dispatch_latency_ms, buckets [50 ... 60_000]
  • executor.execution.latency_ms -> keeperhub_executor_execution_latency_ms, buckets [100 ... 300_000]
  • plus executor.broadcast.latency_ms -> keeperhub_executor_broadcast_latency_ms (point 3 below)

The DB-sourced-gauges note is addressed in a comment directly above the registration: workflow.execution.duration_ms / workflow.step.duration_ms moved to DB gauges because the database holds their per-row data; these measure the SQS queue leg and the dispatch hand-off — intervals between in-memory stages that leave no database timestamps — so runtime histograms are the deliberate exception, not an oversight.

Regression test: lib/metrics/__tests__/executor-latency.test.ts records a sample via recordLatency and asserts it lands in the exposition (_count and _bucket lines) — the exact failure mode ("nothing would have caught this") is now caught.

2. Correlation id out of histogram labels (lib/metrics/types.ts)

LabelKeys.CORRELATION_ID is deleted. Histograms are labeled trigger_type, dispatch_target, and (for the two split-by-stage histograms) stage. The "short enough for labels" comment in latency.ts is rewritten to state the opposite: the id lives in the structured logs and KH_CORRELATION_ID, never in metric labels. A test asserts LabelKeys contains no correlation_id.

3. broadcast is marked where the transaction goes to the chain

Marks now sit at four real broadcast points:

  • lib/web3/chain-adapter/evm.tssendTransaction (both the sign-once-failover path and the legacy signer.sendTransaction fallback) and executeContractCall (failover + legacy fn(...) path)
  • lib/web3/chain-adapter/solana.ts — after submitSignedSolanaTransactionWithFailover resolves a signature
  • lib/web3/sponsored-transaction-manager.ts — after Turnkey's Gas Station accepts the submit

The structural constraint: the write paths run in a context with no handle on the ExecutionLatency instance (separate runner pod for k8s-job dispatches; a separate engine call for in-process). So the timestamp travels by side channel, best-effort, never able to fail the transaction it observes:

  • keeperhub-executor/lib/broadcast-marker.ts writes {executionId, broadcastAt} to a fixed /tmp sidecar (one web3 write in flight per pod by construction) and bumps a process-local counter.
  • The counter ships to the executor in the existing counter-delta ingest (keeperhub_executor_broadcasts_total) — the broadcast stage is visible fleet-wide even where the timestamp is not.
  • In-process: recordInProcessLatency reads the marker back after executeWorkflow returns, marks broadcast, and records the observed -> broadcast distribution (executor.broadcast.latency_ms) — the headline interval from End-to-end trigger-to-broadcast latency is inferred from code, never measured #2289.
  • k8s-job: the runner reads the marker after the engine returns and logs Broadcast stage: executionId=... correlationId=... broadcastAt=.... Ephemeral pods deliberately do not ship histogram observations (metrics-shipping.ts), so the per-run log line joined on correlation id is the record for the Job path; a central histogram for Jobs would need point-sample ingestion first, which I left out rather than half-build. Happy to add that ingestion if you want it.

4. logInfo for the latency lines (keeperhub-executor/latency.ts + call sites)

The hand-built summaryLine is deleted. ExecutionLatency.emitLog() emits via lib/logging's logInfo(message, labels) — canonical shape, correlation id, workflow/execution ids, stage timestamps and durations as labels. Scoped to the latency lines only; the surrounding console.log calls are untouched. On the bundle question: the executor already imports lib/logging transitively (index.ts -> backstop-capture -> logSecurityEvent), and in-process.ts pulls in the engine which imports it directly, so @sentry/nextjs was already in both graphs — tsc and the full executor suite are clean.

Smaller point

correlation.ts got its trailing newline and a comment explaining the duplication ("keeperhub-events is a separate pnpm workspace and cannot import root lib/", same convention as phantom.ts / sqs-message-auth.ts).

Tests

152 executor tests + 7 web3/metrics tests pass, including 7 new broadcast-marker tests (sidecar write/read/clear, corrupt-file tolerance, ALS execution-id resolution, counter bumps), 5 histogram-observability tests, and rewritten emitLog assertions. tsc --noEmit is clean across the repo.

One note for CI: the fork build check may sit at "awaiting approval" per the bot comment above — nothing needed from you on that.

…central histograms

Closes the one gap the PR left open: executor.broadcast.latency_ms only
filled for in-process runs, but real web3 writes dispatch to k8s Jobs,
whose pods cannot write the executor's histograms (histogram observations
cannot be merged across pods without losing bucket fidelity).

Point observations can. The pod ships one duration per stage interval over
the existing metrics ingest; the executor folds each sample into the
originating run's timeline and the central histograms:

- runner collects observed->broadcast (sidecar marker + KH_OBSERVED_AT)
  and received->completed (KH_RECEIVED_AT, injected via k8s-job.ts)
- ship-metrics posts them as an optional observations array on the ingest
  payload; empty deltas + pending observations still post
- correlation-map keeps the run's ExecutionLatency reachable by
  correlation id (bounded, oldest-evicted) until its observations land
- observation-applier marks the timeline (broadcast epoch reconstructed
  from observed + pod duration), records executor.broadcast.latency_ms
  for every Job broadcast, and records executor.execution.latency_ms
  stage=completed anchored on the executor's own received stamp (a skewed
  pod clock cannot distort the queue leg); unknown correlation ids lose
  nothing for self-contained intervals and skip only start-anchored ones
- ingest route returns obsApplied/obsSkipped; a bad observation is
  skipped, never rejected

Tests: 168 executor (16 new: collection guards, map bounds, applier
anchoring/skips, observations-on-the-wire), tsc clean.
@thesithunyein

Copy link
Copy Markdown
Author

Follow-up: the door I left open in the reply above is now closed in 14c4390.

Point latency observations from runner pods. executor.broadcast.latency_ms previously filled only for in-process runs; real web3 writes dispatch to k8s Jobs, whose pods cannot write the executor's histograms (your own metrics-shipping.ts comment: histogram observations cannot be merged across pods without losing bucket fidelity). Point observations can, so:

  • the pod ships {correlationId, executionId, stage, durationMs} over the existing counter-delta ingest (KH_RECEIVED_AT/KH_OBSERVED_AT injected by k8s-job.ts, broadcast from the sidecar marker)
  • the executor folds each sample back onto the originating run's timeline (bounded correlation map) and into the central histograms — so Job broadcasts now fill the observed→broadcast distribution, the headline interval from End-to-end trigger-to-broadcast latency is inferred from code, never measured #2289
  • completion is anchored on the executor's own received stamp, so a skewed pod clock cannot distort the queue leg; unknown correlation ids lose nothing for self-contained intervals

16 new tests (168 executor total, tsc clean). Happy to trim this if the PR has grown past what you want in one change — the observation modules are self-contained (latency-observations / correlation-map / observation-applier) and split cleanly if you'd prefer them as a follow-up PR.

@thesithunyein

Copy link
Copy Markdown
Author

Hi @joelorzet — friendly ping ahead of the hackathon submission deadline (Sep 18). All four review points are fixed (same-day, with tests — the executor suite is now at 168), and the follow-up in 14c4390 also closes the Job-pod dispatch-path gap for the broadcast distribution. I see release: To Prod is being cut; happy to rebase if staging moved, and glad to address anything further or split the PR if that helps review. Thanks again for the precise first pass!

- export executorBroadcastsTotal (metrics-shipping destructures it; the
  unexported binding only passed ad-hoc tsc runs, not type-check:tsc:executor)
- normalize triggerType to "manual" at collection so PendingObservation
  matches the strict LatencyObservation wire contract

pnpm type-check:tsc:executor, pnpm type-check:tsc clean; 168/168 executor tests.
@thesithunyein

Copy link
Copy Markdown
Author

Small hardening commit (1667d01) while preparing the demo recording: running the repo's own scripts — pnpm type-check:tsc:executor and pnpm type-check:tsc — surfaced two loose ends my ad-hoc checks had missed:

  1. executorBroadcastsTotal was module-private in lib/metrics/collectors/prometheus.ts while metrics-shipping.ts destructures it — now exported (with a comment explaining the pod-side shipping path).
  2. collectLatencyObservations accepted triggerType: string | undefined but the LatencyObservation wire contract is a strict string — now normalized at collection ("manual", matching the platform's label for non-trigger-originated runs) so the types line up without loosening the contract.

Both type-check:tsc:executor and type-check:tsc are clean; 168/168 executor tests pass. No behavior change beyond the defaulting above.

@suisuss

suisuss commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Following up on the three metric pieces rather than reopening the review. Two are genuinely fixed; the third is still dead in production, for a different reason than before.

executor.dispatch.latency_ms and executor.execution.latency_ms - fixed, and pinned by a real test. Both are registered (lib/metrics/collectors/prometheus.ts:769-783), both are in histogramMap (:1487-1488), and both are recorded - dispatch at keeperhub-executor/index.ts:350 and in-process.ts:194, execution at in-process.ts:203 and lib/observation-applier.ts:84. lib/metrics/__tests__/executor-latency.test.ts drives the actual collector and greps the rendered registry for the bucket lines, so it would have caught the original "every sample discarded" failure. That is the right shape of test for this.

The broadcast stage is still never marked. The plumbing is all there now - histogram at prometheus.ts:793-800, mark("broadcast", ...) in both paths, five markBroadcast() call sites in lib/web3. But markBroadcast resolves its execution id from the async-local workflow context:

  • keeperhub-executor/lib/broadcast-marker.ts:67-72 returns early when executionId is falsy, having only bumped a module-local counter.
  • currentExecutionId() reads getWorkflowErrorContext()?.execution_id.
  • lib/workflow/executor/error-context.ts:33 initialises storage to null, and the only caller of setWorkflowErrorContextStorage is instrumentation.ts:72-75, behind process.env.NEXT_RUNTIME === "nodejs".
  • The executor stage of the Dockerfile (:274-304) builds from a bare node:24-alpine and copies keeperhub-executor, lib, plugins, protocols, package.json and tsconfig.json from source. It does not copy instrumentation.ts, and it runs tsx keeperhub-executor/index.ts. The workflow-runner stage is the same shape.

So there is no Next.js register(), storage stays null, currentExecutionId() is always undefined, and the histogram holds zero samples. broadcast-marker.test.ts:30-33 encodes that as expected - it asserts currentExecutionId() is undefined - and every other test in the suite passes an explicit executionId, so nothing exercises the real call path.

And there is a second dead metric, which is the same defect class. keeperhub_executor_broadcasts_total is registered at prometheus.ts:810, listed in SHIPPABLE_COUNTER_NAMES at metrics-shipping.ts:75, and documented at METRICS_REFERENCE.md:650. markBroadcast bumps broadcastCount at broadcast-marker.ts:69, and getBroadcastCount() at :89 is read by nothing outside broadcast-marker.test.ts. Nothing increments the registered counter, so it ships zero deltas forever. broadcast-marker.ts:15-17 and metrics-shipping.ts:12-15 both describe this counter as the fallback that makes the broadcast stage observable when the sidecar cannot be read back, so the fallback is empty too.

One more that would keep the Job half empty even after the above is fixed. takeBroadcastMarker is read-and-clear - broadcast-marker.ts:97-110 does readFileSync then an unconditional rmSync. workflow-runner.ts:301 calls it for a log line, and :315 calls collectLatencyObservations, which calls it again at latency-observations.ts:78 and gets undefined. Deterministic rather than a race, so executor.broadcast.latency_ms{dispatch_target="k8s-job"} would stay empty regardless.

And the fixed marker path has a concurrency assumption the executor breaks. broadcast-marker.ts:10-15 justifies the fixed filename on the grounds that only one web3 write can be in flight per pod, "one execution per Job pod, and in-process executions hold the event loop". index.ts:1201 is Promise.allSettled(messages.map(...)) with maxMessages: 10 (config.ts:139), so up to ten in-process executions run concurrently and every await yields. Run A marks, run B overwrites, A takes B's marker and the executionId guard rejects it - and the take has already deleted the file, so B's sample is lost silently with no skip counter. The guard prevents misattribution and converts the race into under-counting.

Smaller, and only worth doing while you are in there: keeperhub-executor/index.ts:72-81 has two separate imports from ./lib/metrics-shipping with LatencyObservation, peekLatency and takeLatency unused - biome.jsonc excludes !keeperhub-executor, so noUnusedImports will not catch it. keeperhub-executor/latency.ts has no trailing newline, which is the same thing that was flagged on correlation.ts and fixed there. And prometheus.ts:786-792 still says a central histogram for Jobs "would need point-sample ingestion first", which the second commit in this branch added.

The correlation-id half is untouched by all of this and still stands on its own: the tracker mints the id, k8s-job.ts:127-131 injects KH_CORRELATION_ID, and nothing in that chain reads a histogram, a marker file or the applier. If it helps to land something, that half ships correctly with the metrics half reverted - the reverse does not hold, since the observation round-trip needs the correlation id. The only piece that has to stay either way is latency.ts, since emitLog is now the sole log emitter for both index.ts and in-process.ts.

Second-pass review found the broadcast half of the KeeperHub#2289 instrumentation
dead in production dispatch. All four defects are fixed:

1. ALS never registered in the executor/runner processes (no
   instrumentation.ts in either Docker stage) -> currentExecutionId() was
   always undefined, so markBroadcast() dropped every sidecar write and
   executor.broadcast.latency_ms stayed empty for in-process runs. New
   side-effect bootstrap module registers the storage the same way
   instrumentation.ts register() does in the Next runtime; imported by
   keeperhub-executor/index.ts and workflow-runner.ts.

2. keeperhub_executor_broadcasts_total was registered and shipped but
   never incremented (getBroadcastCount() had no readers outside tests).
   markBroadcast() now increments the registered counter via lazy import,
   buffering marks that land before the collector resolves so no sample
   is lost, and staying a no-op where the stack is unavailable.

3. takeBroadcastMarker() was called twice per run (runner log line, then
   the observation collector), so the second call deterministically got
   undefined and the Job-pod broadcast histogram stayed empty. The log
   line now peeks (non-destructive) and the collector takes.

4. The single fixed marker filename raced under the executor's
   Promise.allSettled(maxMessages: 10) in-process dispatch: run B could
   overwrite run A's marker before A read it back, silently losing the
   sample. The registry is now one file per execution id
   (KH_BROADCAST_MARKER_DIR), and take is by explicit execution id, so a
   concurrent run can only ever consume its own marker.

Smaller points, same pass: dropped two unused imports from index.ts
(peekLatency/takeLatency moved to their sole reader in the applier;
unused LatencyObservation type), added the missing trailing newline to
latency.ts, and updated the stale prometheus.ts comment that still said
a central histogram for Jobs would need point-sample ingestion first -
the follow-up commit in this branch added exactly that.

Tests: 171 executor tests pass (3 new for the ALS bootstrap, 1 for the
registered-counter flush, 1 for take-consumes semantics; broadcast-marker
suite rewritten for the per-execution registry). tsc clean via the
repo's own type-check:tsc:executor and type-check:tsc.
@thesithunyein

Copy link
Copy Markdown
Author

Thank you for the second pass - every finding checked out against the code, and all four defects are fixed in 39f978e.

1. The broadcast stage is now marked in production dispatch

Root cause as you diagnosed: no Next register() in either Docker stage, so storage stayed null and currentExecutionId() was always undefined. Fixed at the same layer the platform uses for this exact problem:

  • new keeperhub-executor/lib/workflow-error-context-bootstrap.ts - a side-effect import that calls setWorkflowErrorContextStorage(new AsyncLocalStorage()), the same thing instrumentation.ts does behind NEXT_RUNTIME === "nodejs", minus the Next runtime;
  • imported by keeperhub-executor/index.ts (in-process path) and workflow-runner.ts (Job path), so both dispatch targets get a resolvable context;
  • the engine's existing enterWorkflowErrorContext({execution_id, ...}) at run start then does the rest - no engine changes.

workflow-error-context-bootstrap.test.ts proves the chain end-to-end: after the bootstrap import, enterWorkflowErrorContext({execution_id}) becomes readable and currentExecutionId() returns it - the exact call path markBroadcast() uses.

2. The counter now increments

markBroadcast() increments the registered keeperhub_executor_broadcasts_total via lazy import (eager would drag the server-only metrics stack into every lib/web3 write path). Marks landing before the import resolves are buffered and flushed on arrival, so nothing is lost; where the stack never becomes available the increment degrades to the process-local counter instead of throwing. broadcast-marker.test.ts now drives the real registered counter and asserts its value grows (registry-backed, not the module-local count).

3. The double-take is gone

The runner's log line now uses a new non-destructive peekBroadcastMarker(executionId); collectLatencyObservations keeps takeBroadcastMarker(executionId) as the sole consumer. Regression test: a second collect after the first sees a consumed marker and does not double-ship the observation (a retry after a failed shipment is the realistic second caller).

4. Concurrency: the registry is now per-execution

The sidecar moved from one fixed filename to a directory (KH_BROADCAST_MARKER_DIR, default /tmp/kh-broadcast-markers) with one file per execution id. takeBroadcastMarker(executionId) removes only that execution's file, so with Promise.allSettled(maxMessages: 10) in-process dispatch, run B overwriting run A is structurally impossible - A can only ever consume its own marker. Isolation test: two markers coexist, taking one leaves the other intact. Stale files from crashed pods are overwritten by a retry of the same execution or consumed by its take; nothing sweeps the directory, same bounded-lifecycle stance as before.

Smaller points

Both unused imports dropped from index.ts (peekLatency/takeLatency live only in the applier now; the unused LatencyObservation type import removed). latency.ts has its trailing newline. The prometheus.ts comment now says Jobs fill the broadcast histogram via point-sample ingestion (which the follow-up commit in this branch added) instead of claiming it would need ingestion "first".

Verification

Your own scripts, clean: pnpm type-check:tsc:executor, pnpm type-check:tsc. pnpm test:executor: 171 passing (was 168; +3 ALS bootstrap, +1 counter flush, +1 take-consumes, marker suite rewritten for the per-execution registry).

On the landing shape: with the broadcast half now live in production dispatch, the correlation/metrics split you sketched is no longer needed - the whole PR is coherent. The offer stands though: if a smaller landing is easier to review, latency-observations / correlation-map / observation-applier split cleanly along the lines you drew. Happy to rebase if staging moved under this.

@suisuss suisuss left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All seven land, and the tests exercise the real call paths rather than the shape. keeperhub-executor/lib/workflow-error-context-bootstrap.ts:29 registers the AsyncLocalStorage as a side-effect import from index.ts:88 and workflow-runner.ts:48, which closes the chain - executor.workflow.ts:2150-2157 already enters the context at run start and step-handler.ts:262 wraps every step, so currentExecutionId() now resolves in both non-Next processes and the test asserts the real path instead of the broken condition. The counter is incremented at broadcast-marker.ts:76 through a lazy import with pre-resolution marks buffered, and the test reads the registered counter rather than the module-local count. peekBroadcastMarker at :120-132 leaves collectLatencyObservations as the sole consumer, with a regression test that a second collect does not re-ship. One file per execution under MARKER_DIR makes the cross-run overwrite structurally impossible. Duplicate imports, the trailing newline and the stale prometheus.ts comment are all fixed.

I fact-checked the comment: every mechanical claim holds. One is looser than stated - in the executor, loadShippableCounters only runs when an ingest POST arrives, so the first in-process broadcast may well precede resolution; the buffer covers it, so the conclusion stands but the reasoning does not.

Blocking

  • keeperhub-executor/lib/broadcast-marker.ts:39 with in-process.ts:110 - the per-execution registry has no sweep. MARKER_DIR appears only at :39, :48 and :81, rmSync only at :137 on a single resolved path, and there is no readdirSync, no TTL and no startup clean. recordInProcessLatency is the only in-process consumer and it sits inside the try, after mark("completed"); the catch at :133 deliberately does not call it. -> An in-process run that broadcasts and then throws leaves /tmp/kh-broadcast-markers/<executionId>.json behind forever, in a pod that runs for weeks. Each file is tiny, so inodes rather than bytes are the bound, in an emptyDir with a size limit. The module doc at :23-25 says these are "cleaned up by the runner's take", which is true only on the success path. The fix for the fixed-filename race converted a leak bounded at exactly one stale file into an unbounded one. -> Sweep on executor startup, or take in the catch.

Mechanical - actionable as-is

  • broadcast-marker.ts:47-49 - join(MARKER_DIR, \${executionId}.json`)with no sanitisation, and that value now drives both a path and anrmSync`. Ids are DB-generated and SQS messages are HMAC-signed, so this is hardening rather than a live hole, but the value became load-bearing for a delete in this commit.

  • latency-observations.ts:79 dropped the marker.executionId === executionId cross-check while in-process.ts:187 kept it, and parseMarker validates shape without comparing the parsed id to the file it came from. Harmless while markBroadcast is the only writer; the inconsistency between the two consumers is what will confuse the next reader.

  • broadcast-marker.test.ts:88-101 reads before, awaits one setImmediate, marks twice, awaits one more, then asserts after >= before + 2. One macrotask tick does not guarantee a dynamic ESM import of prometheus.ts has settled; it passes today only because three earlier tests already triggered that import. Awaiting the module's own promise makes it deterministic rather than ordering-dependent.

  • executor.workflow.ts:2150 uses enterWith rather than run(), which mutates the current async resource's store rather than scoping a callback, under ten-way in-process concurrency. It works because the runs are on distinct async resources by the time executeWorkflow runs, and the step-level runWithWorkflowErrorContext is a proper run() and is the path web3 writes actually take. Worth knowing that the weaker of the two mechanisms is the one the module doc credits.

With the team

  • Whether keeperhub-executor/lib/broadcast-marker.ts is safe inside the Workflow DevKit bundle. lib/web3/chain-adapter/evm.ts:6 imports it statically; it already carried node:fs and node:path, and it now also carries a dynamic import of lib/metrics/collectors/prometheus, which begins with import "server-only". error-context.ts's own header warns that anything reachable from workflow-executor.workflow.ts must hold zero Node builtins. If the chain adapter is reachable from the workflow bundle rather than only from "use step" Node-runtime code, the DevKit build breaks - and neither type-check:tsc nor test:executor would catch it. I flagged this last round and it has grown rather than shrunk. A full pnpm build settles it in one run; I am getting that answer rather than asking you to guess.

Verdict

Changes requested on the unswept marker directory - all seven items are genuinely fixed, and the fix for the concurrency one introduced a leak that a weeks-long pod will accumulate.

The correlation-id half is still untouched and still independently shippable, and workflow-error-context-bootstrap.ts should stay whichever way the rest goes - without it the executor and runner lose workflow and org attribution on every error log, not just on markBroadcast. That is a wider fix than this PR needed to make.

@suisuss suisuss added the decision-needed Blocked on a maintainer decision, not on the contributor label Sep 12, 2026
…reads

The per-execution marker registry had no bounded cleanup path: the success
take removed a run's file only when executeWorkflow returned normally, so
an in-process run that broadcast and then threw left its marker in
/tmp/kh-broadcast-markers forever, in a pod that runs for weeks.

Three removal paths now cover every failure mode:
- the in-process catch discards the run's own marker (best-effort, cleanup
  only - no latency stage is recorded on the failure path, so histograms
  keep counting only runs that reached a terminal state)
- the executor sweeps the whole registry at startup, before the consumer
  can start any run; covers a process killed mid-run. Runner pods mount
  their own emptyDir and are untouched
- the module doc now lists all three paths instead of crediting only the
  success take

Also from review:
- executionId is allowlisted ([A-Za-z0-9_-]{1,128}, covers nanoid and
  UUID) before it drives a path or an rmSync; unsafe ids still count as
  broadcasts but write no file
- peek/take reject markers whose content id does not match the requested
  id, so no consumer can receive a mismatched marker;
  collectLatencyObservations keeps an explicit symmetric check with
  in-process.ts
- the counter test awaits the lazy import's own promise via
  waitForBroadcastCounterForTests instead of one setImmediate tick, and
  asserts exactly rather than a range

Executor suite: 177 passing (13 marker/cleanup tests, including an
integration test that broadcasts then throws through the real
executeInProcess catch). type-check:tsc:executor and type-check:tsc clean.
@thesithunyein

Copy link
Copy Markdown
Author

Thanks. Every finding checked out, including the correction to my comment's reasoning. You're right that loadShippableCounters only runs when an ingest POST arrives, so it's the buffer, not the import timing, that covers the first in-process broadcast. All fixed in 1ec86a2.

Blocking: the registry is now bounded on three paths.

  1. The in-process catch discards the run's own marker before it touches the DB (in-process.ts:143-155, take at :149). A run that broadcasts and then throws leaves nothing behind. This is cleanup only, no latency stage gets recorded on the failure path, so the histograms keep counting only runs that reached a terminal state.

  2. sweepBroadcastMarkers() (broadcast-marker.ts:200) runs from listen() at index.ts:1038, after the env assertions and before the health server and the consumer start. Every file present at that point is a leftover from a process that died mid-run. Runner pods mount their own emptyDir, so the sweep can't touch a marker belonging to a live dispatch.

  3. The module doc (broadcast-marker.ts:24-35) now lists all three paths instead of crediting only the success take.

An integration test pins this through the real executeInProcess (in-process-broadcast-cleanup.test.ts:78). The engine mock broadcasts through the real markBroadcast, throws, and the test asserts the file is gone and the row lands on error.

Mechanical, all four:

  • Added a SAFE_EXECUTION_ID allowlist (broadcast-marker.ts:68). I checked the repo first: generateId() is nanoid over [0-9a-z] (lib/utils/id.ts), so [A-Za-z0-9_-]{1,128} accepts every id the platform issues and rejects separators, .. and control characters before they reach join() or rmSync. An unsafe id still counts as a broadcast but writes no file (:102). The sidecar is an optimization, the counters are the guarantee. Test at broadcast-marker.test.ts:106.

  • The id cross-check now lives in the readers themselves. peek and take return undefined when the content id doesn't match the requested id (broadcast-marker.ts:135-189), so no consumer can receive a mismatched marker, and take still consumes the misfiled file. collectLatencyObservations also keeps the explicit check so it stays symmetric with in-process.ts (latency-observations.ts:78-82). Test: broadcast-marker.test.ts:92.

  • The counter test awaits the lazy import's own promise through waitForBroadcastCounterForTests() (broadcast-marker.ts:289) and asserts exactly before + 2 (broadcast-marker.test.ts:142).

  • No code change at executor.workflow.ts:2150 since you established enterWith holds here. The comment now says the mechanism honestly: it's the weaker of the two, it holds because concurrent runs sit on distinct async resources, and web3 writes take the proper run() at step level (executor.workflow.ts:2151-2160). Happy to switch it to run() in a follow-up if you'd rather the code carry the stronger mechanism.

Executor suite: 177 passing, up from 168. type-check:tsc:executor and type-check:tsc are clean. I left the DevKit bundle question to your full pnpm build as you said. If the build wants that static import moved, say the word and I'll restructure it same day.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Triage: reviewed, changes needed from the contributor decision-needed Blocked on a maintainer decision, not on the contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants