apcore-js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +11 -0
- package/.gitmessage +60 -0
- package/.pre-commit-config.yaml +28 -0
- package/CHANGELOG.md +47 -0
- package/CLAUDE.md +68 -0
- package/README.md +131 -0
- package/apcore-logo.svg +79 -0
- package/package.json +37 -0
- package/planning/acl-system/overview.md +54 -0
- package/planning/acl-system/plan.md +92 -0
- package/planning/acl-system/state.json +76 -0
- package/planning/acl-system/tasks/acl-core.md +226 -0
- package/planning/acl-system/tasks/acl-rule.md +92 -0
- package/planning/acl-system/tasks/conditional-rules.md +259 -0
- package/planning/acl-system/tasks/pattern-matching.md +152 -0
- package/planning/acl-system/tasks/yaml-loading.md +271 -0
- package/planning/core-executor/overview.md +53 -0
- package/planning/core-executor/plan.md +88 -0
- package/planning/core-executor/state.json +76 -0
- package/planning/core-executor/tasks/async-support.md +106 -0
- package/planning/core-executor/tasks/execution-pipeline.md +113 -0
- package/planning/core-executor/tasks/redaction.md +85 -0
- package/planning/core-executor/tasks/safety-checks.md +65 -0
- package/planning/core-executor/tasks/setup.md +75 -0
- package/planning/decorator-bindings/overview.md +62 -0
- package/planning/decorator-bindings/plan.md +104 -0
- package/planning/decorator-bindings/state.json +87 -0
- package/planning/decorator-bindings/tasks/binding-directory.md +79 -0
- package/planning/decorator-bindings/tasks/binding-loader.md +148 -0
- package/planning/decorator-bindings/tasks/explicit-schemas.md +85 -0
- package/planning/decorator-bindings/tasks/function-module.md +127 -0
- package/planning/decorator-bindings/tasks/module-factory.md +89 -0
- package/planning/decorator-bindings/tasks/schema-modes.md +142 -0
- package/planning/middleware-system/overview.md +48 -0
- package/planning/middleware-system/plan.md +102 -0
- package/planning/middleware-system/state.json +65 -0
- package/planning/middleware-system/tasks/adapters.md +170 -0
- package/planning/middleware-system/tasks/base.md +115 -0
- package/planning/middleware-system/tasks/logging-middleware.md +304 -0
- package/planning/middleware-system/tasks/manager.md +313 -0
- package/planning/observability/overview.md +53 -0
- package/planning/observability/plan.md +119 -0
- package/planning/observability/state.json +98 -0
- package/planning/observability/tasks/context-logger.md +201 -0
- package/planning/observability/tasks/exporters.md +121 -0
- package/planning/observability/tasks/metrics-collector.md +162 -0
- package/planning/observability/tasks/metrics-middleware.md +141 -0
- package/planning/observability/tasks/obs-logging-middleware.md +179 -0
- package/planning/observability/tasks/span-model.md +120 -0
- package/planning/observability/tasks/tracing-middleware.md +179 -0
- package/planning/overview.md +81 -0
- package/planning/registry-system/overview.md +57 -0
- package/planning/registry-system/plan.md +114 -0
- package/planning/registry-system/state.json +109 -0
- package/planning/registry-system/tasks/dependencies.md +157 -0
- package/planning/registry-system/tasks/entry-point.md +148 -0
- package/planning/registry-system/tasks/metadata.md +198 -0
- package/planning/registry-system/tasks/registry-core.md +323 -0
- package/planning/registry-system/tasks/scanner.md +172 -0
- package/planning/registry-system/tasks/schema-export.md +261 -0
- package/planning/registry-system/tasks/types.md +124 -0
- package/planning/registry-system/tasks/validation.md +177 -0
- package/planning/schema-system/overview.md +56 -0
- package/planning/schema-system/plan.md +121 -0
- package/planning/schema-system/state.json +98 -0
- package/planning/schema-system/tasks/exporter.md +153 -0
- package/planning/schema-system/tasks/loader.md +106 -0
- package/planning/schema-system/tasks/ref-resolver.md +133 -0
- package/planning/schema-system/tasks/strict-mode.md +140 -0
- package/planning/schema-system/tasks/typebox-generation.md +133 -0
- package/planning/schema-system/tasks/types-and-annotations.md +160 -0
- package/planning/schema-system/tasks/validator.md +149 -0
- package/src/acl.ts +188 -0
- package/src/bindings.ts +208 -0
- package/src/config.ts +24 -0
- package/src/context.ts +75 -0
- package/src/decorator.ts +110 -0
- package/src/errors.ts +369 -0
- package/src/executor.ts +348 -0
- package/src/index.ts +81 -0
- package/src/middleware/adapters.ts +54 -0
- package/src/middleware/base.ts +33 -0
- package/src/middleware/index.ts +6 -0
- package/src/middleware/logging.ts +103 -0
- package/src/middleware/manager.ts +105 -0
- package/src/module.ts +41 -0
- package/src/observability/context-logger.ts +201 -0
- package/src/observability/index.ts +4 -0
- package/src/observability/metrics.ts +212 -0
- package/src/observability/tracing.ts +187 -0
- package/src/registry/dependencies.ts +99 -0
- package/src/registry/entry-point.ts +64 -0
- package/src/registry/index.ts +8 -0
- package/src/registry/metadata.ts +111 -0
- package/src/registry/registry.ts +314 -0
- package/src/registry/scanner.ts +150 -0
- package/src/registry/schema-export.ts +177 -0
- package/src/registry/types.ts +32 -0
- package/src/registry/validation.ts +38 -0
- package/src/schema/annotations.ts +67 -0
- package/src/schema/exporter.ts +93 -0
- package/src/schema/index.ts +14 -0
- package/src/schema/loader.ts +270 -0
- package/src/schema/ref-resolver.ts +235 -0
- package/src/schema/strict.ts +128 -0
- package/src/schema/types.ts +73 -0
- package/src/schema/validator.ts +82 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/pattern.ts +30 -0
- package/tests/helpers.ts +30 -0
- package/tests/integration/test-acl-safety.test.ts +268 -0
- package/tests/integration/test-binding-executor.test.ts +194 -0
- package/tests/integration/test-e2e-flow.test.ts +117 -0
- package/tests/integration/test-error-propagation.test.ts +259 -0
- package/tests/integration/test-middleware-chain.test.ts +120 -0
- package/tests/integration/test-observability-integration.test.ts +438 -0
- package/tests/observability/test-context-logger.test.ts +123 -0
- package/tests/observability/test-metrics.test.ts +89 -0
- package/tests/observability/test-tracing.test.ts +131 -0
- package/tests/registry/test-dependencies.test.ts +70 -0
- package/tests/registry/test-entry-point.test.ts +133 -0
- package/tests/registry/test-metadata.test.ts +265 -0
- package/tests/registry/test-registry.test.ts +140 -0
- package/tests/registry/test-scanner.test.ts +257 -0
- package/tests/registry/test-schema-export.test.ts +224 -0
- package/tests/registry/test-validation.test.ts +75 -0
- package/tests/schema/test-loader.test.ts +97 -0
- package/tests/schema/test-ref-resolver.test.ts +105 -0
- package/tests/schema/test-strict.test.ts +139 -0
- package/tests/schema/test-validator.test.ts +64 -0
- package/tests/test-acl.test.ts +206 -0
- package/tests/test-bindings.test.ts +227 -0
- package/tests/test-config.test.ts +76 -0
- package/tests/test-context.test.ts +151 -0
- package/tests/test-decorator.test.ts +173 -0
- package/tests/test-errors.test.ts +204 -0
- package/tests/test-executor.test.ts +252 -0
- package/tests/test-middleware-manager.test.ts +185 -0
- package/tests/test-middleware.test.ts +86 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Implementation Plan: Observability
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement the three pillars of observability -- tracing, metrics, and logging -- as middleware-integrated components that provide runtime visibility into the apcore execution pipeline. Each pillar operates through a `Middleware` subclass that uses stack-based state in the shared `context.data` record to handle nested module-to-module calls correctly.
|
|
6
|
+
|
|
7
|
+
## Architecture Design
|
|
8
|
+
|
|
9
|
+
### Component Structure
|
|
10
|
+
|
|
11
|
+
The observability module is organized into three source files, each housing one pillar and its associated middleware:
|
|
12
|
+
|
|
13
|
+
- **Tracing** (`observability/tracing.ts`, ~190 lines) -- `Span` interface describing a trace span with `traceId`, `spanId`, `parentSpanId`, timing, status, attributes, and events. `createSpan()` factory function generates spans with `randomBytes(8).toString('hex')` for span IDs. `SpanExporter` interface with two implementations: `StdoutExporter` (JSON.stringify to console.log) and `InMemoryExporter` (bounded array, shift()-based eviction, configurable max 10,000). `TracingMiddleware` extends `Middleware` with stack-based span management and 4 sampling strategies.
|
|
14
|
+
|
|
15
|
+
- **Metrics** (`observability/metrics.ts`, ~210 lines) -- `MetricsCollector` class with counter (`increment`) and histogram (`observe`) primitives. Uses string-encoded composite keys in the format `name|key1=val1,key2=val2` stored in `Map<string, number>`. No thread locking required (Node.js single-threaded). Histogram support with configurable buckets (13 Prometheus-standard defaults), `+Inf` bucket, and full Prometheus text format export. Convenience methods `incrementCalls()`, `incrementErrors()`, `observeDuration()` for apcore-standard metrics. `MetricsMiddleware` extends `Middleware` with stack-based `performance.now()` timing.
|
|
16
|
+
|
|
17
|
+
- **Logging** (`observability/context-logger.ts`, ~200 lines) -- `ContextLogger` class with 6 log levels (trace/debug/info/warn/error/fatal mapped to numeric values 0-50), JSON and text output formats, automatic `_secret_`-prefixed key redaction, and `fromContext()` static factory that binds trace ID, module ID, and caller ID. Output targets a `WritableOutput` interface (defaults to `process.stderr`). `ObsLoggingMiddleware` extends `Middleware` with stack-based timing and configurable input/output logging.
|
|
18
|
+
|
|
19
|
+
### Three Pillars Architecture
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Executor Pipeline
|
|
23
|
+
|
|
|
24
|
+
+--------------+--------------+
|
|
25
|
+
| | |
|
|
26
|
+
TracingMiddleware MetricsMiddleware ObsLoggingMiddleware
|
|
27
|
+
| | |
|
|
28
|
+
SpanExporter MetricsCollector ContextLogger
|
|
29
|
+
/ \ | |
|
|
30
|
+
StdoutExp InMemoryExp Prometheus JSON/Text
|
|
31
|
+
Export Output
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Data Flow
|
|
35
|
+
|
|
36
|
+
Each middleware hooks into the executor pipeline at three points: `before()`, `after()`, and `onError()`. State is carried across nested calls via the shared `context.data` record using reserved keys:
|
|
37
|
+
|
|
38
|
+
1. **TracingMiddleware** stores a span stack at `context.data['_tracing_spans']` and a sampling decision at `context.data['_tracing_sampled']`. On `before()`, a new span is created and pushed onto the stack with the current top-of-stack span as parent. On `after()`/`onError()`, the span is popped, finalized with end time and status, and conditionally exported based on the sampling decision.
|
|
39
|
+
|
|
40
|
+
2. **MetricsMiddleware** stores a timing stack at `context.data['_metrics_starts']`. On `before()`, `performance.now()` is pushed. On `after()`/`onError()`, the start time is popped and duration computed as `(performance.now() - startTime) / 1000` (converted to seconds for Prometheus conventions).
|
|
41
|
+
|
|
42
|
+
3. **ObsLoggingMiddleware** stores a timing stack at `context.data['_obs_logging_starts']`. On `before()`, `performance.now()` is pushed and a "Module call started" log entry is emitted. On `after()`, the start time is popped, duration computed in milliseconds, and "Module call completed" is emitted. On `onError()`, "Module call failed" is emitted with error details.
|
|
43
|
+
|
|
44
|
+
### Technical Choices and Rationale
|
|
45
|
+
|
|
46
|
+
- **`performance.now()` for timing**: Provides monotonic millisecond resolution. Unlike `Date.now()`, it is not affected by system clock adjustments. Unlike Python's `time.time()` (wall-clock seconds), this returns monotonic milliseconds, so MetricsMiddleware divides by 1000 for Prometheus-standard seconds.
|
|
47
|
+
|
|
48
|
+
- **String-encoded composite keys**: `MetricsCollector` stores counters and histogram data using keys in the format `name|key1=val1,key2=val2`. This avoids the overhead of nested Maps or object hashing and allows simple Map lookups. Labels are sorted alphabetically for deterministic key generation.
|
|
49
|
+
|
|
50
|
+
- **No thread locking**: Node.js runs JavaScript on a single thread. Unlike the Python implementation which requires threading locks for metric state, no synchronization primitives are needed here.
|
|
51
|
+
|
|
52
|
+
- **`randomBytes(8).toString('hex')` for span IDs**: Produces 16-character hex strings (64 bits of entropy). Uses Node.js `node:crypto` for cryptographic randomness rather than `crypto.randomUUID().slice(0,16)` to avoid partial UUID collisions.
|
|
53
|
+
|
|
54
|
+
- **Bounded array with `shift()` for InMemoryExporter**: Simple FIFO eviction. Python uses `collections.deque(maxlen=...)` which is O(1) for left-pop; JavaScript `Array.shift()` is O(n) but acceptable for the expected span volumes. A ring buffer could be a future optimization.
|
|
55
|
+
|
|
56
|
+
- **No OTel bridge**: An `OTLPExporter` is not yet implemented. The `SpanExporter` interface is designed to be compatible with future OpenTelemetry integration, but no OTel SDK dependency exists today.
|
|
57
|
+
|
|
58
|
+
- **`WritableOutput` interface for logger**: Abstracts the output target to support both `process.stderr` (default) and test buffers. Avoids coupling to Node.js streams directly.
|
|
59
|
+
|
|
60
|
+
## Task Breakdown
|
|
61
|
+
|
|
62
|
+
```mermaid
|
|
63
|
+
graph TD
|
|
64
|
+
T1[span-model] --> T2[exporters]
|
|
65
|
+
T1 --> T3[tracing-middleware]
|
|
66
|
+
T2 --> T3
|
|
67
|
+
T4[metrics-collector] --> T5[metrics-middleware]
|
|
68
|
+
T6[context-logger] --> T7[obs-logging-middleware]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Task ID | Title | Estimated Time | Dependencies |
|
|
72
|
+
|---------|-------|---------------|--------------|
|
|
73
|
+
| span-model | Span interface and createSpan() factory | 1h | none |
|
|
74
|
+
| exporters | StdoutExporter and InMemoryExporter | 1.5h | span-model |
|
|
75
|
+
| tracing-middleware | TracingMiddleware with sampling strategies | 3h | span-model, exporters |
|
|
76
|
+
| metrics-collector | MetricsCollector with Prometheus export | 3h | none |
|
|
77
|
+
| metrics-middleware | MetricsMiddleware with stack-based timing | 2h | metrics-collector |
|
|
78
|
+
| context-logger | ContextLogger with formats and redaction | 2h | none |
|
|
79
|
+
| obs-logging-middleware | ObsLoggingMiddleware with structured logging | 2h | context-logger |
|
|
80
|
+
|
|
81
|
+
## Risks and Considerations
|
|
82
|
+
|
|
83
|
+
- **`Array.shift()` performance in InMemoryExporter**: Eviction via `shift()` is O(n) per call when the buffer is full. For high-throughput tracing with maxSpans in the thousands, this is acceptable. If maxSpans grows to tens of thousands, consider a ring buffer implementation.
|
|
84
|
+
- **Missing OTLPExporter**: Production deployments expecting OpenTelemetry Protocol export will need a custom `SpanExporter` implementation or a future `OTLPExporter` addition. The `SpanExporter` interface is stable and ready for this extension.
|
|
85
|
+
- **Prometheus export is in-memory only**: `MetricsCollector.exportPrometheus()` generates text on demand. There is no HTTP endpoint or push gateway integration. Consumers must call `exportPrometheus()` and serve or ship the output themselves.
|
|
86
|
+
- **Sampling decision propagation**: The `_tracing_sampled` flag is set once per context (on first `before()` call) and inherited by all nested spans. This means proportional sampling is all-or-nothing per trace, not per span. The `error_first` strategy overrides this by always exporting error spans regardless of the sampling decision.
|
|
87
|
+
- **Logger output blocking**: `process.stderr.write()` is synchronous for small payloads on most platforms. High-volume logging could introduce backpressure in latency-sensitive paths.
|
|
88
|
+
|
|
89
|
+
## Acceptance Criteria
|
|
90
|
+
|
|
91
|
+
- [x] `Span` interface defines all required fields (traceId, spanId, parentSpanId, name, startTime, endTime, status, attributes, events)
|
|
92
|
+
- [x] `createSpan()` generates unique 16-hex-char span IDs via `randomBytes(8).toString('hex')`
|
|
93
|
+
- [x] `StdoutExporter` serializes spans via `JSON.stringify` to `console.log`
|
|
94
|
+
- [x] `InMemoryExporter` respects `maxSpans` limit with FIFO eviction via `shift()`
|
|
95
|
+
- [x] `InMemoryExporter.getSpans()` returns a defensive copy (spread into new array)
|
|
96
|
+
- [x] `TracingMiddleware` supports all 4 sampling strategies: full, proportional, error_first, off
|
|
97
|
+
- [x] `TracingMiddleware` validates sampling rate (0.0-1.0) and strategy on construction
|
|
98
|
+
- [x] Nested spans correctly chain via `parentSpanId` using stack-based management
|
|
99
|
+
- [x] `error_first` strategy always exports error spans even when not sampled
|
|
100
|
+
- [x] `MetricsCollector` supports counter increment and histogram observe operations
|
|
101
|
+
- [x] String-encoded keys use sorted labels for deterministic composite key generation
|
|
102
|
+
- [x] `exportPrometheus()` produces valid Prometheus text format with HELP, TYPE, buckets, sum, count
|
|
103
|
+
- [x] `MetricsMiddleware` records calls, errors, and duration via stack-based timing
|
|
104
|
+
- [x] Duration is converted from milliseconds to seconds for Prometheus conventions
|
|
105
|
+
- [x] `ContextLogger` supports JSON and text output formats with level filtering
|
|
106
|
+
- [x] `_secret_`-prefixed keys are automatically redacted to `***REDACTED***`
|
|
107
|
+
- [x] `fromContext()` binds traceId, moduleId, and callerId from a `Context` instance
|
|
108
|
+
- [x] `ObsLoggingMiddleware` emits structured start/complete/error log entries with timing
|
|
109
|
+
- [x] All tests pass with `vitest`; zero errors from `tsc --noEmit`
|
|
110
|
+
|
|
111
|
+
## References
|
|
112
|
+
|
|
113
|
+
- `src/observability/tracing.ts` -- Span model, exporters, and TracingMiddleware
|
|
114
|
+
- `src/observability/metrics.ts` -- MetricsCollector and MetricsMiddleware
|
|
115
|
+
- `src/observability/context-logger.ts` -- ContextLogger and ObsLoggingMiddleware
|
|
116
|
+
- `src/observability/index.ts` -- Public API barrel exports
|
|
117
|
+
- `tests/observability/test-tracing.test.ts` -- Tracing tests
|
|
118
|
+
- `tests/observability/test-metrics.test.ts` -- Metrics tests
|
|
119
|
+
- `tests/observability/test-context-logger.test.ts` -- Logger tests
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"feature": "observability",
|
|
3
|
+
"created": "2026-02-16T00:00:00Z",
|
|
4
|
+
"updated": "2026-02-16T00:00:00Z",
|
|
5
|
+
"status": "completed",
|
|
6
|
+
"execution_order": [
|
|
7
|
+
"span-model",
|
|
8
|
+
"exporters",
|
|
9
|
+
"tracing-middleware",
|
|
10
|
+
"metrics-collector",
|
|
11
|
+
"metrics-middleware",
|
|
12
|
+
"context-logger",
|
|
13
|
+
"obs-logging-middleware"
|
|
14
|
+
],
|
|
15
|
+
"progress": {
|
|
16
|
+
"total_tasks": 7,
|
|
17
|
+
"completed": 7,
|
|
18
|
+
"in_progress": 0,
|
|
19
|
+
"pending": 0
|
|
20
|
+
},
|
|
21
|
+
"tasks": [
|
|
22
|
+
{
|
|
23
|
+
"id": "span-model",
|
|
24
|
+
"file": "tasks/span-model.md",
|
|
25
|
+
"title": "Span Interface and createSpan() Factory",
|
|
26
|
+
"status": "completed",
|
|
27
|
+
"started_at": "2026-02-16T08:00:00Z",
|
|
28
|
+
"completed_at": "2026-02-16T09:00:00Z",
|
|
29
|
+
"assignee": null,
|
|
30
|
+
"commits": []
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "exporters",
|
|
34
|
+
"file": "tasks/exporters.md",
|
|
35
|
+
"title": "StdoutExporter and InMemoryExporter",
|
|
36
|
+
"status": "completed",
|
|
37
|
+
"started_at": "2026-02-16T09:00:00Z",
|
|
38
|
+
"completed_at": "2026-02-16T10:30:00Z",
|
|
39
|
+
"assignee": null,
|
|
40
|
+
"commits": []
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "tracing-middleware",
|
|
44
|
+
"file": "tasks/tracing-middleware.md",
|
|
45
|
+
"title": "TracingMiddleware with Sampling Strategies",
|
|
46
|
+
"status": "completed",
|
|
47
|
+
"started_at": "2026-02-16T10:30:00Z",
|
|
48
|
+
"completed_at": "2026-02-16T13:30:00Z",
|
|
49
|
+
"assignee": null,
|
|
50
|
+
"commits": []
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "metrics-collector",
|
|
54
|
+
"file": "tasks/metrics-collector.md",
|
|
55
|
+
"title": "MetricsCollector with Counters, Histograms, Prometheus Export",
|
|
56
|
+
"status": "completed",
|
|
57
|
+
"started_at": "2026-02-16T08:00:00Z",
|
|
58
|
+
"completed_at": "2026-02-16T11:00:00Z",
|
|
59
|
+
"assignee": null,
|
|
60
|
+
"commits": []
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "metrics-middleware",
|
|
64
|
+
"file": "tasks/metrics-middleware.md",
|
|
65
|
+
"title": "MetricsMiddleware with Stack-Based Timing",
|
|
66
|
+
"status": "completed",
|
|
67
|
+
"started_at": "2026-02-16T11:00:00Z",
|
|
68
|
+
"completed_at": "2026-02-16T13:00:00Z",
|
|
69
|
+
"assignee": null,
|
|
70
|
+
"commits": []
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "context-logger",
|
|
74
|
+
"file": "tasks/context-logger.md",
|
|
75
|
+
"title": "ContextLogger with JSON/Text Formats and Redaction",
|
|
76
|
+
"status": "completed",
|
|
77
|
+
"started_at": "2026-02-16T08:00:00Z",
|
|
78
|
+
"completed_at": "2026-02-16T10:00:00Z",
|
|
79
|
+
"assignee": null,
|
|
80
|
+
"commits": []
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "obs-logging-middleware",
|
|
84
|
+
"file": "tasks/obs-logging-middleware.md",
|
|
85
|
+
"title": "ObsLoggingMiddleware with Structured Logging",
|
|
86
|
+
"status": "completed",
|
|
87
|
+
"started_at": "2026-02-16T10:00:00Z",
|
|
88
|
+
"completed_at": "2026-02-16T12:00:00Z",
|
|
89
|
+
"assignee": null,
|
|
90
|
+
"commits": []
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"metadata": {
|
|
94
|
+
"source_doc": "planning/features/observability.md",
|
|
95
|
+
"created_by": "code-forge",
|
|
96
|
+
"version": "1.0"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Task: ContextLogger with JSON/Text Formats and Redaction
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement `ContextLogger` that provides structured logging with JSON and text output formats, numeric level filtering (trace/debug/info/warn/error/fatal), automatic redaction of `_secret_`-prefixed keys, and a `fromContext()` static factory method that binds trace, module, and caller metadata from a `Context` instance.
|
|
6
|
+
|
|
7
|
+
## Files Involved
|
|
8
|
+
|
|
9
|
+
- `src/observability/context-logger.ts` -- ContextLogger class, LEVELS map, WritableOutput interface
|
|
10
|
+
- `src/context.ts` -- Context class (dependency for `fromContext()`)
|
|
11
|
+
- `tests/observability/test-context-logger.test.ts` -- Unit tests for ContextLogger
|
|
12
|
+
|
|
13
|
+
## Steps (TDD)
|
|
14
|
+
|
|
15
|
+
### 1. Write failing tests for basic logging
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
function createBufferOutput() {
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
return {
|
|
21
|
+
output: { write: (s: string) => lines.push(s) },
|
|
22
|
+
lines,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('ContextLogger', () => {
|
|
27
|
+
it('logs JSON format by default', () => {
|
|
28
|
+
const { output, lines } = createBufferOutput();
|
|
29
|
+
const logger = new ContextLogger({ output });
|
|
30
|
+
logger.info('test message');
|
|
31
|
+
expect(lines).toHaveLength(1);
|
|
32
|
+
const parsed = JSON.parse(lines[0]);
|
|
33
|
+
expect(parsed.level).toBe('info');
|
|
34
|
+
expect(parsed.message).toBe('test message');
|
|
35
|
+
expect(parsed.timestamp).toBeDefined();
|
|
36
|
+
expect(parsed.logger).toBe('apcore');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('logs text format', () => {
|
|
40
|
+
const { output, lines } = createBufferOutput();
|
|
41
|
+
const logger = new ContextLogger({ format: 'text', output });
|
|
42
|
+
logger.info('test message');
|
|
43
|
+
expect(lines[0]).toContain('[INFO]');
|
|
44
|
+
expect(lines[0]).toContain('test message');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Define WritableOutput interface and level map
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
interface WritableOutput {
|
|
53
|
+
write(s: string): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const LEVELS: Record<string, number> = {
|
|
57
|
+
trace: 0,
|
|
58
|
+
debug: 10,
|
|
59
|
+
info: 20,
|
|
60
|
+
warn: 30,
|
|
61
|
+
error: 40,
|
|
62
|
+
fatal: 50,
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Implement ContextLogger constructor with options
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
export class ContextLogger {
|
|
70
|
+
constructor(options?: {
|
|
71
|
+
name?: string; // default: 'apcore'
|
|
72
|
+
format?: string; // 'json' (default) or 'text'
|
|
73
|
+
level?: string; // minimum level, default: 'info'
|
|
74
|
+
redactSensitive?: boolean; // default: true
|
|
75
|
+
output?: WritableOutput; // default: process.stderr
|
|
76
|
+
}) { /* ... */ }
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 4. Write failing tests for level filtering
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
it('respects log level filtering', () => {
|
|
84
|
+
const { output, lines } = createBufferOutput();
|
|
85
|
+
const logger = new ContextLogger({ level: 'warn', output });
|
|
86
|
+
logger.debug('should not appear');
|
|
87
|
+
logger.info('should not appear');
|
|
88
|
+
logger.warn('should appear');
|
|
89
|
+
logger.error('should appear');
|
|
90
|
+
expect(lines).toHaveLength(2);
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 5. Implement _emit() with level check and dual format output
|
|
95
|
+
|
|
96
|
+
The internal `_emit()` method:
|
|
97
|
+
1. Checks if the message level meets the minimum threshold
|
|
98
|
+
2. Redacts `_secret_`-prefixed keys in the `extra` record if `redactSensitive` is true
|
|
99
|
+
3. Builds a log entry record with `timestamp`, `level`, `message`, `trace_id`, `module_id`, `caller_id`, `logger`, `extra`
|
|
100
|
+
4. Serializes as JSON (`JSON.stringify + newline`) or text (`timestamp [LEVEL] [trace=...] [module=...] message extras`)
|
|
101
|
+
|
|
102
|
+
### 6. Write failing tests for redaction
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
it('redacts _secret_ prefix keys', () => {
|
|
106
|
+
const { output, lines } = createBufferOutput();
|
|
107
|
+
const logger = new ContextLogger({ output });
|
|
108
|
+
logger.info('test', { _secret_token: 'abc123', name: 'Bob' });
|
|
109
|
+
const parsed = JSON.parse(lines[0]);
|
|
110
|
+
expect(parsed.extra._secret_token).toBe('***REDACTED***');
|
|
111
|
+
expect(parsed.extra.name).toBe('Bob');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not redact when disabled', () => {
|
|
115
|
+
const { output, lines } = createBufferOutput();
|
|
116
|
+
const logger = new ContextLogger({ output, redactSensitive: false });
|
|
117
|
+
logger.info('test', { _secret_token: 'abc123' });
|
|
118
|
+
const parsed = JSON.parse(lines[0]);
|
|
119
|
+
expect(parsed.extra._secret_token).toBe('abc123');
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### 7. Implement _secret_ redaction in _emit()
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
if (extra != null && this._redactSensitive) {
|
|
127
|
+
redactedExtra = {};
|
|
128
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
129
|
+
redactedExtra[k] = k.startsWith('_secret_') ? '***REDACTED***' : v;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 8. Write failing tests for fromContext()
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
it('fromContext sets trace/module/caller', () => {
|
|
138
|
+
const { output, lines } = createBufferOutput();
|
|
139
|
+
const ctx = Context.create(undefined, createIdentity('user1'));
|
|
140
|
+
const childCtx = ctx.child('mod.test');
|
|
141
|
+
const logger = ContextLogger.fromContext(childCtx, 'test-logger', { output });
|
|
142
|
+
logger.info('context log');
|
|
143
|
+
const parsed = JSON.parse(lines[0]);
|
|
144
|
+
expect(parsed.trace_id).toBe(ctx.traceId);
|
|
145
|
+
expect(parsed.module_id).toBe('mod.test');
|
|
146
|
+
expect(parsed.logger).toBe('test-logger');
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 9. Implement fromContext() static factory
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
static fromContext(context: Context, name: string, options?: {
|
|
154
|
+
format?: string;
|
|
155
|
+
level?: string;
|
|
156
|
+
redactSensitive?: boolean;
|
|
157
|
+
output?: WritableOutput;
|
|
158
|
+
}): ContextLogger {
|
|
159
|
+
const logger = new ContextLogger({ name, ...options });
|
|
160
|
+
logger._traceId = context.traceId;
|
|
161
|
+
logger._moduleId = context.callChain.length > 0
|
|
162
|
+
? context.callChain[context.callChain.length - 1]
|
|
163
|
+
: null;
|
|
164
|
+
logger._callerId = context.callerId;
|
|
165
|
+
return logger;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### 10. Implement all 6 log level methods
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
trace(message: string, extra?: Record<string, unknown>): void { this._emit('trace', message, extra); }
|
|
173
|
+
debug(message: string, extra?: Record<string, unknown>): void { this._emit('debug', message, extra); }
|
|
174
|
+
info(message: string, extra?: Record<string, unknown>): void { this._emit('info', message, extra); }
|
|
175
|
+
warn(message: string, extra?: Record<string, unknown>): void { this._emit('warn', message, extra); }
|
|
176
|
+
error(message: string, extra?: Record<string, unknown>): void { this._emit('error', message, extra); }
|
|
177
|
+
fatal(message: string, extra?: Record<string, unknown>): void { this._emit('fatal', message, extra); }
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### 11. Run tests and verify all pass
|
|
181
|
+
|
|
182
|
+
## Acceptance Criteria
|
|
183
|
+
|
|
184
|
+
- [x] `ContextLogger` supports JSON (default) and text output formats
|
|
185
|
+
- [x] 6 log levels: trace (0), debug (10), info (20), warn (30), error (40), fatal (50)
|
|
186
|
+
- [x] Level filtering suppresses messages below the configured minimum level
|
|
187
|
+
- [x] `_secret_`-prefixed extra keys redacted to `***REDACTED***` when `redactSensitive` is true (default)
|
|
188
|
+
- [x] Redaction can be disabled via `redactSensitive: false`
|
|
189
|
+
- [x] `fromContext()` binds `traceId`, `moduleId` (last in callChain), and `callerId` from a Context
|
|
190
|
+
- [x] JSON format includes: timestamp, level, message, trace_id, module_id, caller_id, logger, extra
|
|
191
|
+
- [x] Text format: `timestamp [LEVEL] [trace=...] [module=...] message extras`
|
|
192
|
+
- [x] Default output is `process.stderr`; custom `WritableOutput` accepted via options
|
|
193
|
+
- [x] All tests pass with `vitest`
|
|
194
|
+
|
|
195
|
+
## Dependencies
|
|
196
|
+
|
|
197
|
+
- None (independent pillar, but uses `Context` from core-executor for `fromContext()`)
|
|
198
|
+
|
|
199
|
+
## Estimated Time
|
|
200
|
+
|
|
201
|
+
2 hours
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Task: StdoutExporter and InMemoryExporter
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement two `SpanExporter` implementations: `StdoutExporter` that serializes spans to stdout via `JSON.stringify`, and `InMemoryExporter` that stores spans in a bounded array with FIFO eviction. Note: `OTLPExporter` (OpenTelemetry Protocol) is not implemented -- this is a known gap for future work.
|
|
6
|
+
|
|
7
|
+
## Files Involved
|
|
8
|
+
|
|
9
|
+
- `src/observability/tracing.ts` -- StdoutExporter and InMemoryExporter classes
|
|
10
|
+
- `tests/observability/test-tracing.test.ts` -- Unit tests for both exporters
|
|
11
|
+
|
|
12
|
+
## Steps (TDD)
|
|
13
|
+
|
|
14
|
+
### 1. Write failing tests for InMemoryExporter
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
describe('InMemoryExporter', () => {
|
|
18
|
+
it('collects and retrieves spans', () => {
|
|
19
|
+
const exporter = new InMemoryExporter();
|
|
20
|
+
const span = createSpan({ traceId: 't1', name: 'test', startTime: 0 });
|
|
21
|
+
exporter.export(span);
|
|
22
|
+
expect(exporter.getSpans()).toHaveLength(1);
|
|
23
|
+
expect(exporter.getSpans()[0].traceId).toBe('t1');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('respects max_spans limit', () => {
|
|
27
|
+
const exporter = new InMemoryExporter(3);
|
|
28
|
+
for (let i = 0; i < 5; i++) {
|
|
29
|
+
exporter.export(createSpan({ traceId: `t${i}`, name: 'test', startTime: i }));
|
|
30
|
+
}
|
|
31
|
+
const spans = exporter.getSpans();
|
|
32
|
+
expect(spans).toHaveLength(3);
|
|
33
|
+
expect(spans[0].traceId).toBe('t2'); // oldest evicted
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('clear removes all spans', () => {
|
|
37
|
+
const exporter = new InMemoryExporter();
|
|
38
|
+
exporter.export(createSpan({ traceId: 't1', name: 'test', startTime: 0 }));
|
|
39
|
+
exporter.clear();
|
|
40
|
+
expect(exporter.getSpans()).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('getSpans returns defensive copy', () => {
|
|
44
|
+
const exporter = new InMemoryExporter();
|
|
45
|
+
exporter.export(createSpan({ traceId: 't1', name: 'test', startTime: 0 }));
|
|
46
|
+
const spans = exporter.getSpans();
|
|
47
|
+
spans.pop(); // mutate the copy
|
|
48
|
+
expect(exporter.getSpans()).toHaveLength(1); // original unaffected
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 2. Implement StdoutExporter
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
export class StdoutExporter implements SpanExporter {
|
|
57
|
+
export(span: Span): void {
|
|
58
|
+
console.log(JSON.stringify(span));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Simple passthrough: serialize the span as a JSON string and write to stdout via `console.log`. No buffering or batching.
|
|
64
|
+
|
|
65
|
+
### 3. Implement InMemoryExporter
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
export class InMemoryExporter implements SpanExporter {
|
|
69
|
+
private _spans: Span[] = [];
|
|
70
|
+
private _maxSpans: number;
|
|
71
|
+
|
|
72
|
+
constructor(maxSpans: number = 10_000) {
|
|
73
|
+
this._maxSpans = maxSpans;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export(span: Span): void {
|
|
77
|
+
this._spans.push(span);
|
|
78
|
+
while (this._spans.length > this._maxSpans) {
|
|
79
|
+
this._spans.shift(); // FIFO eviction
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getSpans(): Span[] {
|
|
84
|
+
return [...this._spans]; // defensive copy
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
clear(): void {
|
|
88
|
+
this._spans = [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Key design decisions:
|
|
94
|
+
- Default `maxSpans` of 10,000 (configurable via constructor)
|
|
95
|
+
- `shift()` for FIFO eviction (O(n) but acceptable for expected volumes, unlike Python's O(1) `deque.popleft()`)
|
|
96
|
+
- `getSpans()` returns a spread copy to prevent external mutation of internal state
|
|
97
|
+
- No thread locking needed (Node.js single-threaded)
|
|
98
|
+
|
|
99
|
+
### 4. Run tests and verify all pass
|
|
100
|
+
|
|
101
|
+
## Known Gap: OTLPExporter
|
|
102
|
+
|
|
103
|
+
The Python implementation includes an `OTLPExporter` for shipping spans to OpenTelemetry-compatible backends. This is **not yet implemented** in the TypeScript version. The `SpanExporter` interface is designed to be forward-compatible -- a future `OTLPExporter` would implement `export(span: Span): void` and handle HTTP/gRPC transport to an OTLP collector.
|
|
104
|
+
|
|
105
|
+
## Acceptance Criteria
|
|
106
|
+
|
|
107
|
+
- [x] `StdoutExporter` implements `SpanExporter` and calls `console.log(JSON.stringify(span))`
|
|
108
|
+
- [x] `InMemoryExporter` stores spans in a bounded array with configurable `maxSpans` (default 10,000)
|
|
109
|
+
- [x] FIFO eviction via `shift()` when array exceeds `maxSpans`
|
|
110
|
+
- [x] `getSpans()` returns a defensive copy via spread operator
|
|
111
|
+
- [x] `clear()` empties the internal span array
|
|
112
|
+
- [x] OTLPExporter absence is documented as a known gap
|
|
113
|
+
- [x] All tests pass with `vitest`
|
|
114
|
+
|
|
115
|
+
## Dependencies
|
|
116
|
+
|
|
117
|
+
- **span-model** -- Requires `Span` interface and `SpanExporter` interface
|
|
118
|
+
|
|
119
|
+
## Estimated Time
|
|
120
|
+
|
|
121
|
+
1.5 hours
|