business-metrics.test.ts's "human resolution" case drove a ticket
through a real HUMAN_ESCALATION transition via ticketsService.updateStatus,
which triggers the real orchestration subscriber's default ROUND_ROBIN
auto-assignment against every agent in the shared throwaway database —
reproduced deterministically landing on agent-ticket-queue.test.ts's own
dedicated agent. Fixed by driving the intermediate transitions directly
through ticketsRepository.updateStatus (no domain-event publish),
reserving the real, event-publishing call for only the final RESOLVED
transition the metric subscriber needs to observe.
Also documents (checklist Notes), without fixing, a separate pre-existing
issue confirmed unrelated to this feature via git checkout to the clean
013-auth-hardening tip: nearly every integration test file's product ID
collapses to the same 4-letter ticket-code prefix ("TEST"), so enough
concurrent TEST_*-prefixed files can exceed the fixed retry ceiling on
ticket-code generation and surface as a real 500 — a 003-ticketing
concern, out of scope here.
Marks all 30 tasks.md items complete.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
12 KiB
description
| description |
|---|
| Task list for 014-full-observability |
Tasks: Full Observability
Input: Design documents from specs/014-full-observability/
Prerequisites: plan.md, spec.md, research.md, data-model.md, contracts/metrics-contract.md, quickstart.md
Organization: Tasks are grouped by user story (US1 = P1 access log, US2 = P1 request-health
metrics, US3 = P2 tracing, US4 = P2 business-health metrics). US2 shares its hook point with
US1 (both live in the same onResponse hook) so US2 depends on US1's hook existing, not on its
own separate one. US3 and US4 are each independent of US1/US2 and of each other.
Format: [ID] [P?] [Story] Description
All file paths are relative to supporthub-api/ (repo root).
Phase 1: Foundational (Blocking Prerequisites)
- T001 Add
@opentelemetry/exporter-trace-otlp-httpand@opentelemetry/resourcestopackage.json(npm install) - T002 Add
src/infrastructure/observability/request-context.store.ts— a module-levelAsyncLocalStorage<{requestId: string; correlationId: string}>with arun()passthrough and agetStore()re-export - T003 [P] Wire
logger.ts's Pino options with amixinfunction reading from T002's store (returns{}when no store is active — a log call outside any request, e.g. at startup, must not throw) (depends on T002)
Checkpoint: Every subsequent log call through the shared logger singleton is
request-correlated automatically, once a request actually runs inside the store (US1 wires that
part next).
Phase 2: User Story 1 - Trace one request end to end from its logs (Priority: P1)
Goal: One structured access-log line per request; every other log line produced during that request's handling shares its request ID.
Independent Test: Quickstart Scenario 1.
Tests for User Story 1
- T004 [P] [US1] Unit test: a
logger.info(...)call made inside T002'sstore.run(...)carriesrequestId/correlationIdin its output; one made outside carries neither, intests/unit/observability/request-context-mixin.test.ts(depends on T003)
Implementation for User Story 1
- T005 [US1] In
plugins/request-context.plugin.ts's existingonRequesthook, after buildingrequest.reqContext, call the T002 store'srun()wrapping the remainder of the request's handling (Fastify'sonRequesthooks accept adonecallback / return a promise — the run wraps whichever style this hook currently uses) so every subsequent hook/handler for this request executes inside the ALS context (depends on T002) - T006 [US1] Add an
onResponsehook (same plugin) that logs one line via the sharedlogger:{event: "http_request_completed", method, route: request.routeOptions.url, statusCode: reply.statusCode, durationMs: reply.elapsedTime}, atinfo/warn/errorlevel by status class (depends on T005) - T007 [US1] Integration test covering Quickstart Scenario 1 (one access-log line per
request incl. 404; shared requestId across the access-log line and an internal log line
from the same request) in
tests/integration/observability/access-log.test.ts, using alogger.info/logger.warnspy the same waypassword-reset-flow.test.ts(013) already does (depends on T006)
Checkpoint: Quickstart Scenario 1 passes. Every request is now visible in logs even when it never errors.
Phase 3: User Story 2 - See live request-health metrics (Priority: P1)
Goal: The existing (previously dead) request-duration histogram actually has observations;
error rate per route/status is computable from /metrics alone.
Independent Test: Quickstart Scenario 2.
Implementation for User Story 2
- T008 [US2] In the same
onResponsehook added by T006, callhttpRequestDurationHistogram.observe({method, route: request.routeOptions.url, status_code: String(reply.statusCode)}, reply.elapsedTime / 1000)(depends on T006) - T009 [US2] Integration test covering Quickstart Scenario 2 (send a mix of successful/
failing requests to the same route, scrape
/metrics, assert both status-code label values are present with the expected counts) intests/integration/observability/request-metrics.test.ts(depends on T008)
Checkpoint: Quickstart Scenario 2 passes. /metrics now reflects real request traffic.
Phase 4: User Story 3 - Trace a single incident's cross-module path (Priority: P2)
Goal: A real TracerProvider is active; getTracer() produces spans that are actually
exported; two named cross-module paths are instrumented.
Independent Test: Quickstart Scenario 3.
Implementation for User Story 3
- T010 [P] [US3] Rewrite
infrastructure/observability/tracing.tsto initialize aBasicTracerProviderat module load with aResource(service.name: "supporthub-api") and register it viatrace.setGlobalTracerProvider(...); exporter/processor chosen byNODE_ENV/OTEL_EXPORTER_OTLP_ENDPOINTper research.md §4 (InMemorySpanExporter+SimpleSpanProcessorin test,OTLPTraceExporter+BatchSpanProcessorwhen the env var is set,ConsoleSpanExporter+SimpleSpanProcessorotherwise); export agetTestSpanExporter()accessor (test env only) for T015 to read exported spans back;getTracer()'s own exported signature is unchanged (depends on T001) - T011 [US3] Wire the OpenTelemetry SDK's internal diagnostic logger
(
diag.setLogger(...)) to the sharedlogger.warn, so span-export failures land in this project's own log stream instead of stderr or nowhere (depends on T010) - T012 [P] [US3] Add a
ticket.createspan (getTracer().startActiveSpan(...)) aroundtickets/service/tickets.service.ts's ticket-creation method, withticket.idandproduct.externalProductIdattributes, ended in afinally(depends on T010) - T013 [P] [US3] Add an
ai.escalationspan aroundai-support/sessions/service/ session.service.ts's escalation branch(es), withticket.id/session.idattributes (depends on T010) - T014 [US3] Add an
orchestration.assignmentspan wrapping the existingorchestrationService.handleHumanEscalationcall in theTICKET_UPDATED/HUMAN_ESCALATIONsubscriber (src/events/handlers/index.ts), withticket.id/strategyattributes, so it nests under T013's span when both occur in the same request (depends on T010, T013) - T015 [US3] Integration test covering Quickstart Scenario 3 steps 1-2: drive a real
ticket-creation → escalation → orchestration/assignment flow, read spans back via T010's
getTestSpanExporter(), assertticket.create/ai.escalation/orchestration.assignmentall share one trace ID with correct parent/childspanIdrelationships, intests/integration/observability/tracing.test.ts(depends on T012, T013, T014) - T016 [US3] Integration test covering Quickstart Scenario 3 steps 3-4: point
OTEL_EXPORTER_OTLP_ENDPOINTat an unreachable address, confirmbuildApp()still resolves and a request still completes successfully, in the same test file (depends on T010)
Checkpoint: Quickstart Scenario 3 passes. A real, inspectable trace exists for the first time; tracing failure never blocks the app.
Phase 5: User Story 4 - See the business-health metrics this project committed to tracking (Priority: P2)
Goal: All eleven named metrics (data-model.md) are live on /metrics, each updated at the
exact real event research.md identified.
Independent Test: Quickstart Scenario 4.
Implementation for User Story 4
- T017 [US4] Define all eleven new
Counter/Histogramobjects ininfrastructure/observability/metrics.tsper data-model.md's table, exported individually (depends on T001) - T018 [P] [US4] Increment
supporthub_ai_session_outcomes_totalinai-support/sessions/ repository/session.repository.ts'supdateStatus, labeledoutcomewhenstatusis'resolved'/'escalated'(depends on T017) - T019 [P] [US4] Add a
TICKET_UPDATED/newStatus === 'RESOLVED'subscriber insrc/events/handlers/index.tsthat looks upresolutionRepository.findByTicketId, incrementssupporthub_ticket_resolutions_total{resolved_by}(aivs. any other value), fetches the ticket forcreatedAt, and observessupporthub_ticket_resolution_duration_seconds(depends on T017) - T020 [P] [US4] In
ticketing/messages/service/messages.service.ts'spost, whentype === 'AGENT_MESSAGE', check for a priorAGENT_MESSAGEon the ticket and — only for the first one — observesupporthub_ticket_first_response_duration_secondsagainst the ticket'screatedAt(depends on T017) - T021 [P] [US4] In
orchestration/sla/service/sla.service.ts: incomplete(), readrun.statusbefore updating and incrementsupporthub_sla_run_outcomes_total{outcome: "met"}only if it was not already'breached'; inrunBreachDetectionSweep(), increment{outcome: "breached"}for each newly-flagged run (depends on T017) - T022 [P] [US4] Add an
ESCALATION_TRIGGEREDsubscriber insrc/events/handlers/index.tsthat incrementssupporthub_escalations_total{reason}from the event payload'sreason(depends on T017) - T023 [P] [US4] In
ticketing/tickets/service/tickets.service.ts's ticket-creation method, incrementsupporthub_problems_created_total{category_id}right afterproblemsRepo.createsucceeds (categoryId ?? 'uncategorized') (depends on T017) - T024 [P] [US4] In
ai-support/knowledge/service/error-codes.service.ts'sfindKnownIssuesByErrorCode, incrementsupporthub_known_error_lookups_total{code}once the error code is confirmed to exist (after theNotFoundErrorbranch, not before) (depends on T017) - T025 [P] [US4] In
ai-support/tools/service/tools.service.ts's singleexecuteTool(...)call site: incrementsupporthub_tool_invocations_total{tool, outcome}for every call, and — only whenblock.name === 'searchProductKnowledge'— incrementsupporthub_knowledge_retrieval_outcomes_total{matched}from whetherresult.outputis a non-empty array (depends on T017) - T026 [US4] Unit test:
sla.service.ts'scomplete()does not increment themetoutcome for a run already'breached'(a fake repo returningstatus: 'breached') intests/unit/observability/sla-compliance-metric.test.ts(depends on T021) - T027 [US4] Integration test covering Quickstart Scenario 4 (all ten sub-scenarios — the
eleventh, tool-failure, is covered by the same test file's tool-invocation case) —
scrape
/metricsbefore/after driving each real event through the real API, intests/integration/observability/business-metrics.test.ts(depends on T018, T019, T020, T021, T022, T023, T024, T025)
Checkpoint: Quickstart Scenario 4 passes. All eleven named metrics are live and correct against real infrastructure.
Phase 6: Polish & Cross-Cutting Concerns
- T028 [P] Update
specs/014-full-observability/checklists/requirements.mdNotes with any implementation-time findings (including the pre-existing SLA-run status data-quality gap research.md §5 already surfaced) - T029 Run
npx tsx scripts/check-architecture.tsandnpm run lint/npm run typecheck - T030 Full regression:
npm run test:unitthen the full integration suite against real Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed (particularly every module touched by a single-line instrumentation addition: ai-support sessions/knowledge/tools, ticketing tickets/messages, orchestration/sla, and the event-bus handlers)
Dependencies & Execution Order
- Foundational (Phase 1): No dependencies — BLOCKS User Story 1 (and transitively 2)
- User Story 1 (Phase 2): Depends on Foundational — BLOCKS User Story 2 (shares its hook)
- User Story 2 (Phase 3): Depends on User Story 1
- User Story 3 (Phase 4): Depends only on Foundational (T001) — independent of US1/US2/US4
- User Story 4 (Phase 5): Depends only on Foundational (T001/T017) — independent of US1/US2/US3
- Polish (Phase 5): Depends on all four user stories