You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
src/logger.ts:218#createChildLogger defines the canonical contract for per-request structured log fields: { deliveryId, owner, repo, entityNumber }. The doc comment at src/logger.ts:215 explicitly promises "Consistent fields across all log lines for a single request." In practice every webhook event handler bypasses this helper and calls logger.child(...) directly with a hand-rolled field set, and the entity-identifier field name drifts across five handlers and two executors. As a result an operator who knows a PR or issue number cannot grep one field name to reconstruct a request end-to-end, the very correlation property the Observability doc page advertises in its first paragraph.
Concrete drift, verified by reading each file at HEAD (f2f9f46):
src/webhook/events/issues.ts:69 emits issueNumber on issues.labeled.
src/webhook/events/pull-request.ts:148 emits prNumber on pull_request.labeled.
src/webhook/events/issue-comment.ts:48 emits issueNumber even when payload.issue.pull_request !== undefined (the comment is actually on a PR, computed one line later at src/webhook/events/issue-comment.ts:69 as isPR), so the same numeric entity flips field name between issues.labeled and pull_request.labeled depending on the trigger surface.
src/daemon/job-executor.ts:314 is the one site that uses createChildLogger correctly and emits entityNumber.
src/daemon/workflow-executor.ts:60 nests the number inside a target object (target.number), a third shape entirely.
A second, orthogonal gap rides on the same call sites: every event handler captures payload.installation.id into a local installationId (src/webhook/events/issue-comment.ts:64, src/webhook/events/review-comment.ts:93, src/webhook/events/issues.ts:94, src/webhook/events/pull-request.ts:109) and threads it into downstream shippr.installation_id payloads, but never attaches it to the child logger. Multi-tenant operators cannot filter logs by GitHub installation, which is the natural unit of REST/GraphQL rate-limit accounting. The docs/operate/observability.md:19-34 "Common log fields" table reflects the gap, it lists deliveryId, event, repo, dispatch_target, etc., but no field for the entity number or the installation.
The minimal fix is to extend createChildLogger (src/logger.ts:218) with an optional installationId?: number and a discriminator so callers continue to pass either prNumber or issueNumber semantically while the helper writes a single canonical entityNumber (plus the original semantic name for backwards-compat log queries during a transition window), then migrate the six bypass sites to it. The daemon workflow-executor.ts nested target can stay (it's the workflow envelope) but must additionally write a flat entityNumber so per-entity grep aligns with the rest of the fleet. Add a row for the new fields to docs/operate/observability.md:19-34.
Diagram
flowchart LR
classDef webhook fill:#1f4e79;color:#ffffff
classDef daemon fill:#196f3d;color:#ffffff
classDef canonical fill:#7a5800;color:#ffffff
classDef drift fill:#922b21;color:#ffffff
WH[Webhook receiver<br/>deliveryId stable]:::webhook
ISS[events/issues.ts L69<br/>field: issueNumber]:::drift
PRL[events/pull-request.ts L148<br/>field: prNumber]:::drift
ICM[events/issue-comment.ts L48<br/>field: issueNumber]:::drift
RCM[events/review-comment.ts L56<br/>field: prNumber]:::drift
JE[daemon/job-executor.ts L314<br/>field: entityNumber]:::canonical
WE[daemon/workflow-executor.ts L60<br/>field: target.number nested]:::drift
CCL[logger.ts L218<br/>createChildLogger contract<br/>entityNumber]:::canonical
WH --> ISS
WH --> PRL
WH --> ICM
WH --> RCM
ISS --> JE
PRL --> JE
ICM --> JE
RCM --> JE
JE --> WE
CCL -.canonical.-> JE
CCL -.bypassed.-> ISS
CCL -.bypassed.-> PRL
CCL -.bypassed.-> ICM
CCL -.bypassed.-> RCM
CCL -.bypassed.-> WE
Loading
Rationale
The deliveryId correlation chain works end-to-end (one field, threaded through BotContext.deliveryId from webhook to daemon), and the operator-facing observability doc leans on this property: "Every dispatch decision and every pipeline step carries a deliveryId so you can reconstruct a request end-to-end from a single log query" (docs/operate/observability.md:3). The entity-number chain does not work this way. Two real operator workflows break:
Per-entity post-mortem. An on-call sees a flapping PR (say #42 in chrisleekr/foo) and wants every log line for it across a week. With current drift the query has to be (prNumber:42 OR issueNumber:42 OR target.number:42 OR entityNumber:42) AND repo:"chrisleekr/foo". That is four OR branches against a structured store (Loki / Datadog) that bills per-query bytes scanned, and any site added in the future drifts again.
Per-installation tenancy / rate-limit triage. The CLAUDE.md "Authentication options" section makes per-installation isolation a first-class concept (ALLOWED_OWNERS single-tenant gate), and the App's rate-limit bucket is per-installation. When a secondary-rate-limit 403 fires from GitHub there is no installation_id field on the surrounding log lines to scope the blast radius, even though payload.installation.id is available at every webhook entry point.
Both fixes are byte-cheap (one extra field per child-logger creation, ~50 LOC across the seven call sites plus a createChildLogger signature widening), have no runtime cost (pino child loggers fold mixins at log-time), and stay within the existing pino architecture, no new dependency. They unlock the correlation guarantee the doc already advertises.
This matches the pino child() documentation pattern (child loggers exist precisely to bind stable request-scoped fields once) and aligns the codebase with the OpenTelemetry semantic-conventions principle that the same attribute key should denote the same concept across emitters in a single service.
Widen createChildLogger in src/logger.ts:218 to accept installationId?: number and a discriminator for the entity kind (isPR: boolean), and have it emit entityNumber: number as the canonical field plus installationId (omitted via exactOptionalPropertyTypes when absent).
Migrate the five webhook event handlers (issues.ts, pull-request.ts, issue-comment.ts, review-comment.ts, review.ts) to call createChildLogger instead of logger.child. Keep the prior prNumber / issueNumber field for one release as a compatibility-during-transition mirror, then remove in a follow-up.
Update src/daemon/workflow-executor.ts:60 to add a flat entityNumber alongside the existing nested target object so the canonical grep aligns. Do not remove target (it's the workflow envelope).
Add a new row to the "Common log fields" table in docs/operate/observability.md:19-34 for entityNumber and installationId, and update the lede paragraph at docs/operate/observability.md:3 so the correlation promise covers entity-scoped queries too.
Add a small test in src/logger.test.ts (or co-located) that round-trips a createChildLogger output through a pino capture and asserts entityNumber + installationId are emitted on the resulting line, mirroring the schema-pinning pattern used at src/workflows/ship/log-fields.ts.
Areas Evaluated
Read src/logger.ts end-to-end (root logger, redaction lists, errSerializer, createChildLogger). Surveyed every logger.child(...) and createChildLogger(...) call site under src/ via Grep (51 files, 264 logger.<level> call sites, 17 child-logger creation sites). Cross-checked the field names against docs/operate/observability.md "Common log fields" table. Sampled the daemon executors (job-executor.ts, workflow-executor.ts, scheduled-action-executor.ts, scoped-*-executor.ts) for downstream field consistency. Reviewed existing area: observability research issues to confirm non-overlap with: periodic fleet-state gauge, stdio-MCP-server pino logger, octokit rate-limit hook, pipeline.stage events with delta_ms, pino.final on uncaught/unhandled. None of those touch the per-handler field-name drift surfaced here.
Generated by the scheduled research action on 2026-05-28
Finding
src/logger.ts:218#createChildLoggerdefines the canonical contract for per-request structured log fields:{ deliveryId, owner, repo, entityNumber }. The doc comment atsrc/logger.ts:215explicitly promises "Consistent fields across all log lines for a single request." In practice every webhook event handler bypasses this helper and callslogger.child(...)directly with a hand-rolled field set, and the entity-identifier field name drifts across five handlers and two executors. As a result an operator who knows a PR or issue number cannot grep one field name to reconstruct a request end-to-end, the very correlation property the Observability doc page advertises in its first paragraph.Concrete drift, verified by reading each file at HEAD (
f2f9f46):src/webhook/events/issues.ts:69emitsissueNumberonissues.labeled.src/webhook/events/pull-request.ts:148emitsprNumberonpull_request.labeled.src/webhook/events/issue-comment.ts:48emitsissueNumbereven whenpayload.issue.pull_request !== undefined(the comment is actually on a PR, computed one line later atsrc/webhook/events/issue-comment.ts:69asisPR), so the same numeric entity flips field name betweenissues.labeledandpull_request.labeleddepending on the trigger surface.src/webhook/events/review-comment.ts:56emitsprNumber.src/daemon/job-executor.ts:314is the one site that usescreateChildLoggercorrectly and emitsentityNumber.src/daemon/workflow-executor.ts:60nests the number inside atargetobject (target.number), a third shape entirely.A second, orthogonal gap rides on the same call sites: every event handler captures
payload.installation.idinto a localinstallationId(src/webhook/events/issue-comment.ts:64,src/webhook/events/review-comment.ts:93,src/webhook/events/issues.ts:94,src/webhook/events/pull-request.ts:109) and threads it into downstreamshippr.installation_idpayloads, but never attaches it to the child logger. Multi-tenant operators cannot filter logs by GitHub installation, which is the natural unit of REST/GraphQL rate-limit accounting. Thedocs/operate/observability.md:19-34"Common log fields" table reflects the gap, it listsdeliveryId,event,repo,dispatch_target, etc., but no field for the entity number or the installation.The minimal fix is to extend
createChildLogger(src/logger.ts:218) with an optionalinstallationId?: numberand a discriminator so callers continue to pass eitherprNumberorissueNumbersemantically while the helper writes a single canonicalentityNumber(plus the original semantic name for backwards-compat log queries during a transition window), then migrate the six bypass sites to it. The daemonworkflow-executor.tsnestedtargetcan stay (it's the workflow envelope) but must additionally write a flatentityNumberso per-entity grep aligns with the rest of the fleet. Add a row for the new fields todocs/operate/observability.md:19-34.Diagram
Rationale
The
deliveryIdcorrelation chain works end-to-end (one field, threaded throughBotContext.deliveryIdfrom webhook to daemon), and the operator-facing observability doc leans on this property: "Every dispatch decision and every pipeline step carries adeliveryIdso you can reconstruct a request end-to-end from a single log query" (docs/operate/observability.md:3). The entity-number chain does not work this way. Two real operator workflows break:#42inchrisleekr/foo) and wants every log line for it across a week. With current drift the query has to be(prNumber:42 OR issueNumber:42 OR target.number:42 OR entityNumber:42) AND repo:"chrisleekr/foo". That is fourORbranches against a structured store (Loki / Datadog) that bills per-query bytes scanned, and any site added in the future drifts again.ALLOWED_OWNERSsingle-tenant gate), and the App's rate-limit bucket is per-installation. When a secondary-rate-limit 403 fires from GitHub there is noinstallation_idfield on the surrounding log lines to scope the blast radius, even thoughpayload.installation.idis available at every webhook entry point.Both fixes are byte-cheap (one extra field per child-logger creation, ~50 LOC across the seven call sites plus a
createChildLoggersignature widening), have no runtime cost (pino child loggers fold mixins at log-time), and stay within the existing pino architecture, no new dependency. They unlock the correlation guarantee the doc already advertises.This matches the pino
child()documentation pattern (child loggers exist precisely to bind stable request-scoped fields once) and aligns the codebase with the OpenTelemetry semantic-conventions principle that the same attribute key should denote the same concept across emitters in a single service.References
Internal:
src/logger.ts:218#createChildLogger(canonical contract,entityNumber).src/webhook/events/issues.ts:69(issueNumber).src/webhook/events/pull-request.ts:148(prNumber).src/webhook/events/issue-comment.ts:48(issueNumbereven for PR comments, seeisPRatsrc/webhook/events/issue-comment.ts:69).src/webhook/events/review-comment.ts:56(prNumber).src/daemon/job-executor.ts:314(canonical helper used here).src/daemon/workflow-executor.ts:60(target.number, nested).src/webhook/events/issue-comment.ts:64,src/webhook/events/review-comment.ts:93(installation id captured but not logged).docs/operate/observability.md:3(correlation promise) anddocs/operate/observability.md:17(Common log fields table missing entity-number + installation rows).External:
child()docs — child loggers are the supported pattern for stable per-request fields.Suggested Next Steps
createChildLoggerinsrc/logger.ts:218to acceptinstallationId?: numberand a discriminator for the entity kind (isPR: boolean), and have it emitentityNumber: numberas the canonical field plusinstallationId(omitted viaexactOptionalPropertyTypeswhen absent).issues.ts,pull-request.ts,issue-comment.ts,review-comment.ts,review.ts) to callcreateChildLoggerinstead oflogger.child. Keep the priorprNumber/issueNumberfield for one release as a compatibility-during-transition mirror, then remove in a follow-up.src/daemon/workflow-executor.ts:60to add a flatentityNumberalongside the existing nestedtargetobject so the canonical grep aligns. Do not removetarget(it's the workflow envelope).docs/operate/observability.md:19-34forentityNumberandinstallationId, and update the lede paragraph atdocs/operate/observability.md:3so the correlation promise covers entity-scoped queries too.src/logger.test.ts(or co-located) that round-trips acreateChildLoggeroutput through a pino capture and assertsentityNumber+installationIdare emitted on the resulting line, mirroring the schema-pinning pattern used atsrc/workflows/ship/log-fields.ts.Areas Evaluated
Read
src/logger.tsend-to-end (root logger, redaction lists,errSerializer,createChildLogger). Surveyed everylogger.child(...)andcreateChildLogger(...)call site undersrc/via Grep (51 files, 264logger.<level>call sites, 17 child-logger creation sites). Cross-checked the field names againstdocs/operate/observability.md"Common log fields" table. Sampled the daemon executors (job-executor.ts,workflow-executor.ts,scheduled-action-executor.ts,scoped-*-executor.ts) for downstream field consistency. Reviewed existingarea: observabilityresearch issues to confirm non-overlap with: periodic fleet-state gauge, stdio-MCP-server pino logger, octokit rate-limit hook,pipeline.stageevents withdelta_ms,pino.finalon uncaught/unhandled. None of those touch the per-handler field-name drift surfaced here.Generated by the scheduled research action on 2026-05-28