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.
Files changed (142) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/.gitmessage +60 -0
  3. package/.pre-commit-config.yaml +28 -0
  4. package/CHANGELOG.md +47 -0
  5. package/CLAUDE.md +68 -0
  6. package/README.md +131 -0
  7. package/apcore-logo.svg +79 -0
  8. package/package.json +37 -0
  9. package/planning/acl-system/overview.md +54 -0
  10. package/planning/acl-system/plan.md +92 -0
  11. package/planning/acl-system/state.json +76 -0
  12. package/planning/acl-system/tasks/acl-core.md +226 -0
  13. package/planning/acl-system/tasks/acl-rule.md +92 -0
  14. package/planning/acl-system/tasks/conditional-rules.md +259 -0
  15. package/planning/acl-system/tasks/pattern-matching.md +152 -0
  16. package/planning/acl-system/tasks/yaml-loading.md +271 -0
  17. package/planning/core-executor/overview.md +53 -0
  18. package/planning/core-executor/plan.md +88 -0
  19. package/planning/core-executor/state.json +76 -0
  20. package/planning/core-executor/tasks/async-support.md +106 -0
  21. package/planning/core-executor/tasks/execution-pipeline.md +113 -0
  22. package/planning/core-executor/tasks/redaction.md +85 -0
  23. package/planning/core-executor/tasks/safety-checks.md +65 -0
  24. package/planning/core-executor/tasks/setup.md +75 -0
  25. package/planning/decorator-bindings/overview.md +62 -0
  26. package/planning/decorator-bindings/plan.md +104 -0
  27. package/planning/decorator-bindings/state.json +87 -0
  28. package/planning/decorator-bindings/tasks/binding-directory.md +79 -0
  29. package/planning/decorator-bindings/tasks/binding-loader.md +148 -0
  30. package/planning/decorator-bindings/tasks/explicit-schemas.md +85 -0
  31. package/planning/decorator-bindings/tasks/function-module.md +127 -0
  32. package/planning/decorator-bindings/tasks/module-factory.md +89 -0
  33. package/planning/decorator-bindings/tasks/schema-modes.md +142 -0
  34. package/planning/middleware-system/overview.md +48 -0
  35. package/planning/middleware-system/plan.md +102 -0
  36. package/planning/middleware-system/state.json +65 -0
  37. package/planning/middleware-system/tasks/adapters.md +170 -0
  38. package/planning/middleware-system/tasks/base.md +115 -0
  39. package/planning/middleware-system/tasks/logging-middleware.md +304 -0
  40. package/planning/middleware-system/tasks/manager.md +313 -0
  41. package/planning/observability/overview.md +53 -0
  42. package/planning/observability/plan.md +119 -0
  43. package/planning/observability/state.json +98 -0
  44. package/planning/observability/tasks/context-logger.md +201 -0
  45. package/planning/observability/tasks/exporters.md +121 -0
  46. package/planning/observability/tasks/metrics-collector.md +162 -0
  47. package/planning/observability/tasks/metrics-middleware.md +141 -0
  48. package/planning/observability/tasks/obs-logging-middleware.md +179 -0
  49. package/planning/observability/tasks/span-model.md +120 -0
  50. package/planning/observability/tasks/tracing-middleware.md +179 -0
  51. package/planning/overview.md +81 -0
  52. package/planning/registry-system/overview.md +57 -0
  53. package/planning/registry-system/plan.md +114 -0
  54. package/planning/registry-system/state.json +109 -0
  55. package/planning/registry-system/tasks/dependencies.md +157 -0
  56. package/planning/registry-system/tasks/entry-point.md +148 -0
  57. package/planning/registry-system/tasks/metadata.md +198 -0
  58. package/planning/registry-system/tasks/registry-core.md +323 -0
  59. package/planning/registry-system/tasks/scanner.md +172 -0
  60. package/planning/registry-system/tasks/schema-export.md +261 -0
  61. package/planning/registry-system/tasks/types.md +124 -0
  62. package/planning/registry-system/tasks/validation.md +177 -0
  63. package/planning/schema-system/overview.md +56 -0
  64. package/planning/schema-system/plan.md +121 -0
  65. package/planning/schema-system/state.json +98 -0
  66. package/planning/schema-system/tasks/exporter.md +153 -0
  67. package/planning/schema-system/tasks/loader.md +106 -0
  68. package/planning/schema-system/tasks/ref-resolver.md +133 -0
  69. package/planning/schema-system/tasks/strict-mode.md +140 -0
  70. package/planning/schema-system/tasks/typebox-generation.md +133 -0
  71. package/planning/schema-system/tasks/types-and-annotations.md +160 -0
  72. package/planning/schema-system/tasks/validator.md +149 -0
  73. package/src/acl.ts +188 -0
  74. package/src/bindings.ts +208 -0
  75. package/src/config.ts +24 -0
  76. package/src/context.ts +75 -0
  77. package/src/decorator.ts +110 -0
  78. package/src/errors.ts +369 -0
  79. package/src/executor.ts +348 -0
  80. package/src/index.ts +81 -0
  81. package/src/middleware/adapters.ts +54 -0
  82. package/src/middleware/base.ts +33 -0
  83. package/src/middleware/index.ts +6 -0
  84. package/src/middleware/logging.ts +103 -0
  85. package/src/middleware/manager.ts +105 -0
  86. package/src/module.ts +41 -0
  87. package/src/observability/context-logger.ts +201 -0
  88. package/src/observability/index.ts +4 -0
  89. package/src/observability/metrics.ts +212 -0
  90. package/src/observability/tracing.ts +187 -0
  91. package/src/registry/dependencies.ts +99 -0
  92. package/src/registry/entry-point.ts +64 -0
  93. package/src/registry/index.ts +8 -0
  94. package/src/registry/metadata.ts +111 -0
  95. package/src/registry/registry.ts +314 -0
  96. package/src/registry/scanner.ts +150 -0
  97. package/src/registry/schema-export.ts +177 -0
  98. package/src/registry/types.ts +32 -0
  99. package/src/registry/validation.ts +38 -0
  100. package/src/schema/annotations.ts +67 -0
  101. package/src/schema/exporter.ts +93 -0
  102. package/src/schema/index.ts +14 -0
  103. package/src/schema/loader.ts +270 -0
  104. package/src/schema/ref-resolver.ts +235 -0
  105. package/src/schema/strict.ts +128 -0
  106. package/src/schema/types.ts +73 -0
  107. package/src/schema/validator.ts +82 -0
  108. package/src/utils/index.ts +1 -0
  109. package/src/utils/pattern.ts +30 -0
  110. package/tests/helpers.ts +30 -0
  111. package/tests/integration/test-acl-safety.test.ts +268 -0
  112. package/tests/integration/test-binding-executor.test.ts +194 -0
  113. package/tests/integration/test-e2e-flow.test.ts +117 -0
  114. package/tests/integration/test-error-propagation.test.ts +259 -0
  115. package/tests/integration/test-middleware-chain.test.ts +120 -0
  116. package/tests/integration/test-observability-integration.test.ts +438 -0
  117. package/tests/observability/test-context-logger.test.ts +123 -0
  118. package/tests/observability/test-metrics.test.ts +89 -0
  119. package/tests/observability/test-tracing.test.ts +131 -0
  120. package/tests/registry/test-dependencies.test.ts +70 -0
  121. package/tests/registry/test-entry-point.test.ts +133 -0
  122. package/tests/registry/test-metadata.test.ts +265 -0
  123. package/tests/registry/test-registry.test.ts +140 -0
  124. package/tests/registry/test-scanner.test.ts +257 -0
  125. package/tests/registry/test-schema-export.test.ts +224 -0
  126. package/tests/registry/test-validation.test.ts +75 -0
  127. package/tests/schema/test-loader.test.ts +97 -0
  128. package/tests/schema/test-ref-resolver.test.ts +105 -0
  129. package/tests/schema/test-strict.test.ts +139 -0
  130. package/tests/schema/test-validator.test.ts +64 -0
  131. package/tests/test-acl.test.ts +206 -0
  132. package/tests/test-bindings.test.ts +227 -0
  133. package/tests/test-config.test.ts +76 -0
  134. package/tests/test-context.test.ts +151 -0
  135. package/tests/test-decorator.test.ts +173 -0
  136. package/tests/test-errors.test.ts +204 -0
  137. package/tests/test-executor.test.ts +252 -0
  138. package/tests/test-middleware-manager.test.ts +185 -0
  139. package/tests/test-middleware.test.ts +86 -0
  140. package/tsconfig.build.json +8 -0
  141. package/tsconfig.json +20 -0
  142. package/vitest.config.ts +18 -0
@@ -0,0 +1,162 @@
1
+ # Task: MetricsCollector with Counters, Histograms, and Prometheus Export
2
+
3
+ ## Goal
4
+
5
+ Implement `MetricsCollector` that provides in-memory counter and histogram metric primitives with string-encoded composite keys, configurable histogram buckets, and Prometheus text format export. Includes convenience methods for apcore-standard metrics (calls, errors, duration).
6
+
7
+ ## Files Involved
8
+
9
+ - `src/observability/metrics.ts` -- MetricsCollector class, labelsKey(), parseLabels(), formatLabels() helpers
10
+ - `tests/observability/test-metrics.test.ts` -- Unit tests for MetricsCollector
11
+
12
+ ## Steps (TDD)
13
+
14
+ ### 1. Write failing tests for counter operations
15
+
16
+ ```typescript
17
+ describe('MetricsCollector', () => {
18
+ it('increments counters', () => {
19
+ const collector = new MetricsCollector();
20
+ collector.increment('test_counter', { method: 'GET' });
21
+ collector.increment('test_counter', { method: 'GET' });
22
+ const snap = collector.snapshot();
23
+ expect(snap.counters['test_counter|method=GET']).toBe(2);
24
+ });
25
+
26
+ it('supports custom increment amounts', () => {
27
+ const collector = new MetricsCollector();
28
+ collector.increment('test_counter', { method: 'POST' }, 5);
29
+ const snap = collector.snapshot();
30
+ expect(snap.counters['test_counter|method=POST']).toBe(5);
31
+ });
32
+ });
33
+ ```
34
+
35
+ ### 2. Implement string-encoded key system
36
+
37
+ Labels are encoded as sorted key-value pairs joined by commas:
38
+
39
+ ```typescript
40
+ function labelsKey(labels: Record<string, string>): string {
41
+ return Object.entries(labels)
42
+ .sort(([a], [b]) => a.localeCompare(b))
43
+ .map(([k, v]) => `${k}=${v}`)
44
+ .join(',');
45
+ }
46
+ ```
47
+
48
+ Composite keys use the format `name|key1=val1,key2=val2`. Sorting ensures deterministic keys regardless of insertion order. No thread locking is needed because Node.js is single-threaded (unlike the Python implementation which requires locks).
49
+
50
+ ### 3. Implement counter increment
51
+
52
+ ```typescript
53
+ increment(name: string, labels: Record<string, string>, amount: number = 1): void {
54
+ const key = `${name}|${labelsKey(labels)}`;
55
+ this._counters.set(key, (this._counters.get(key) ?? 0) + amount);
56
+ }
57
+ ```
58
+
59
+ ### 4. Write failing tests for histogram operations
60
+
61
+ ```typescript
62
+ it('observes histogram values', () => {
63
+ const collector = new MetricsCollector();
64
+ collector.observe('test_hist', { module: 'a' }, 0.05);
65
+ collector.observe('test_hist', { module: 'a' }, 0.5);
66
+ const snap = collector.snapshot();
67
+ expect(snap.histograms.sums['test_hist|module=a']).toBeCloseTo(0.55);
68
+ expect(snap.histograms.counts['test_hist|module=a']).toBe(2);
69
+ });
70
+ ```
71
+
72
+ ### 5. Implement histogram observe with bucket tracking
73
+
74
+ ```typescript
75
+ observe(name: string, labels: Record<string, string>, value: number): void {
76
+ const lk = labelsKey(labels);
77
+ const key = `${name}|${lk}`;
78
+ this._histogramSums.set(key, (this._histogramSums.get(key) ?? 0) + value);
79
+ this._histogramCounts.set(key, (this._histogramCounts.get(key) ?? 0) + 1);
80
+
81
+ for (const b of this._buckets) {
82
+ if (value <= b) {
83
+ const bkey = `${name}|${lk}|${b}`;
84
+ this._histogramBuckets.set(bkey, (this._histogramBuckets.get(bkey) ?? 0) + 1);
85
+ }
86
+ }
87
+ const infKey = `${name}|${lk}|Inf`;
88
+ this._histogramBuckets.set(infKey, (this._histogramBuckets.get(infKey) ?? 0) + 1);
89
+ }
90
+ ```
91
+
92
+ Default buckets follow Prometheus conventions: `[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0]`.
93
+
94
+ ### 6. Write failing tests for Prometheus export
95
+
96
+ ```typescript
97
+ it('exportPrometheus produces valid format', () => {
98
+ const collector = new MetricsCollector();
99
+ collector.incrementCalls('mod.a', 'success');
100
+ collector.observeDuration('mod.a', 0.05);
101
+ const output = collector.exportPrometheus();
102
+ expect(output).toContain('# HELP apcore_module_calls_total Total module calls');
103
+ expect(output).toContain('# TYPE apcore_module_calls_total counter');
104
+ expect(output).toContain('# TYPE apcore_module_duration_seconds histogram');
105
+ expect(output).toContain('_bucket{');
106
+ expect(output).toContain('le="+Inf"');
107
+ expect(output).toContain('_sum{');
108
+ expect(output).toContain('_count{');
109
+ });
110
+
111
+ it('empty collector returns empty prometheus string', () => {
112
+ const collector = new MetricsCollector();
113
+ expect(collector.exportPrometheus()).toBe('');
114
+ });
115
+ ```
116
+
117
+ ### 7. Implement exportPrometheus()
118
+
119
+ Generates Prometheus text format with:
120
+ - `# HELP` and `# TYPE` headers (emitted once per metric name)
121
+ - Counter lines: `metric_name{label="value"} count`
122
+ - Histogram lines: `_bucket{...,le="bound"}`, `_sum{...}`, `_count{...}`
123
+ - Labels formatted as `{key="value",le="bound"}` with `le` sorted last
124
+
125
+ ### 8. Implement convenience methods and snapshot/reset
126
+
127
+ ```typescript
128
+ incrementCalls(moduleId: string, status: string): void {
129
+ this.increment('apcore_module_calls_total', { module_id: moduleId, status });
130
+ }
131
+ incrementErrors(moduleId: string, errorCode: string): void {
132
+ this.increment('apcore_module_errors_total', { module_id: moduleId, error_code: errorCode });
133
+ }
134
+ observeDuration(moduleId: string, durationSeconds: number): void {
135
+ this.observe('apcore_module_duration_seconds', { module_id: moduleId }, durationSeconds);
136
+ }
137
+ ```
138
+
139
+ ### 9. Run tests and verify all pass
140
+
141
+ ## Acceptance Criteria
142
+
143
+ - [x] `MetricsCollector` stores counters in `Map<string, number>` with string-encoded composite keys
144
+ - [x] `increment()` supports custom amounts (default 1)
145
+ - [x] `observe()` tracks histogram sums, counts, and per-bucket cumulative counts
146
+ - [x] Default histogram buckets match Prometheus standard (13 values + Inf)
147
+ - [x] Custom bucket arrays accepted and sorted on construction
148
+ - [x] `exportPrometheus()` produces valid Prometheus text format with HELP, TYPE, buckets, sum, count
149
+ - [x] Labels sorted alphabetically with `le` last in histogram bucket lines
150
+ - [x] `snapshot()` returns counters and histograms as plain objects
151
+ - [x] `reset()` clears all internal state
152
+ - [x] Convenience methods `incrementCalls()`, `incrementErrors()`, `observeDuration()` use apcore-standard metric names
153
+ - [x] No thread locking (Node.js single-threaded -- differs from Python implementation)
154
+ - [x] All tests pass with `vitest`
155
+
156
+ ## Dependencies
157
+
158
+ - None (independent pillar)
159
+
160
+ ## Estimated Time
161
+
162
+ 3 hours
@@ -0,0 +1,141 @@
1
+ # Task: MetricsMiddleware with Stack-Based Timing
2
+
3
+ ## Goal
4
+
5
+ Implement `MetricsMiddleware` that extends the `Middleware` base class to automatically record call counts, error counts, and execution duration for every module call. Uses a stack-based timing approach via `performance.now()` stored in `context.data` to correctly handle nested module-to-module calls.
6
+
7
+ ## Files Involved
8
+
9
+ - `src/observability/metrics.ts` -- MetricsMiddleware class
10
+ - `src/middleware/base.ts` -- Middleware base class (dependency)
11
+ - `src/context.ts` -- Context class with shared `data` record (dependency)
12
+ - `src/errors.ts` -- ModuleError for error code extraction (dependency)
13
+ - `tests/observability/test-metrics.test.ts` -- Unit tests for MetricsMiddleware
14
+
15
+ ## Steps (TDD)
16
+
17
+ ### 1. Write failing tests for success path
18
+
19
+ ```typescript
20
+ describe('MetricsMiddleware', () => {
21
+ it('records call metrics on success', () => {
22
+ const collector = new MetricsCollector();
23
+ const mw = new MetricsMiddleware(collector);
24
+ const ctx = Context.create();
25
+
26
+ mw.before('mod.a', {}, ctx);
27
+ mw.after('mod.a', {}, { result: 'ok' }, ctx);
28
+
29
+ const snap = collector.snapshot();
30
+ const counters = snap.counters as Record<string, number>;
31
+ expect(counters['apcore_module_calls_total|module_id=mod.a,status=success']).toBe(1);
32
+ });
33
+ });
34
+ ```
35
+
36
+ ### 2. Write failing tests for error path
37
+
38
+ ```typescript
39
+ it('records error metrics on failure', () => {
40
+ const collector = new MetricsCollector();
41
+ const mw = new MetricsMiddleware(collector);
42
+ const ctx = Context.create();
43
+
44
+ mw.before('mod.a', {}, ctx);
45
+ mw.onError('mod.a', {}, new Error('boom'), ctx);
46
+
47
+ const snap = collector.snapshot();
48
+ const counters = snap.counters as Record<string, number>;
49
+ expect(counters['apcore_module_calls_total|module_id=mod.a,status=error']).toBe(1);
50
+ expect(counters['apcore_module_errors_total|error_code=Error,module_id=mod.a']).toBe(1);
51
+ });
52
+ ```
53
+
54
+ ### 3. Implement before() with stack-based timing
55
+
56
+ ```typescript
57
+ export class MetricsMiddleware extends Middleware {
58
+ private _collector: MetricsCollector;
59
+
60
+ constructor(collector: MetricsCollector) {
61
+ super();
62
+ this._collector = collector;
63
+ }
64
+
65
+ override before(
66
+ _moduleId: string,
67
+ _inputs: Record<string, unknown>,
68
+ context: Context,
69
+ ): null {
70
+ const starts = (context.data['_metrics_starts'] as number[]) ?? [];
71
+ starts.push(performance.now());
72
+ context.data['_metrics_starts'] = starts;
73
+ return null;
74
+ }
75
+ }
76
+ ```
77
+
78
+ The timing stack at `context.data['_metrics_starts']` is an array of `performance.now()` values. Each `before()` pushes the current time, and the corresponding `after()`/`onError()` pops it. This stack-based approach correctly pairs start/end times even for nested calls (A calls B calls C).
79
+
80
+ ### 4. Implement after() with duration conversion
81
+
82
+ ```typescript
83
+ override after(
84
+ moduleId: string,
85
+ _inputs: Record<string, unknown>,
86
+ _output: Record<string, unknown>,
87
+ context: Context,
88
+ ): null {
89
+ const starts = context.data['_metrics_starts'] as number[];
90
+ const startTime = starts.pop()!;
91
+ const durationS = (performance.now() - startTime) / 1000;
92
+ this._collector.incrementCalls(moduleId, 'success');
93
+ this._collector.observeDuration(moduleId, durationS);
94
+ return null;
95
+ }
96
+ ```
97
+
98
+ Key detail: `performance.now()` returns monotonic milliseconds, but Prometheus conventions use seconds. The division by 1000 converts to seconds. This differs from Python's `time.time()` which returns wall-clock seconds directly.
99
+
100
+ ### 5. Implement onError() with error code extraction
101
+
102
+ ```typescript
103
+ override onError(
104
+ moduleId: string,
105
+ _inputs: Record<string, unknown>,
106
+ error: Error,
107
+ context: Context,
108
+ ): null {
109
+ const starts = context.data['_metrics_starts'] as number[];
110
+ const startTime = starts.pop()!;
111
+ const durationS = (performance.now() - startTime) / 1000;
112
+ const errorCode = error instanceof ModuleError ? error.code : error.constructor.name;
113
+ this._collector.incrementCalls(moduleId, 'error');
114
+ this._collector.incrementErrors(moduleId, errorCode);
115
+ this._collector.observeDuration(moduleId, durationS);
116
+ return null;
117
+ }
118
+ ```
119
+
120
+ Error code is extracted from `ModuleError.code` (structured apcore errors) or falls back to `error.constructor.name` (generic JS errors).
121
+
122
+ ### 6. Run tests and verify all pass
123
+
124
+ ## Acceptance Criteria
125
+
126
+ - [x] `MetricsMiddleware` extends `Middleware` and accepts a `MetricsCollector` instance
127
+ - [x] `before()` pushes `performance.now()` onto `context.data['_metrics_starts']` stack
128
+ - [x] `after()` pops start time, computes duration in seconds, records success call + duration
129
+ - [x] `onError()` pops start time, computes duration, records error call + error count + duration
130
+ - [x] Duration converted from milliseconds to seconds (`/ 1000`) for Prometheus conventions
131
+ - [x] Error code extracted from `ModuleError.code` or `error.constructor.name`
132
+ - [x] Stack-based timing correctly handles nested module calls
133
+ - [x] All tests pass with `vitest`
134
+
135
+ ## Dependencies
136
+
137
+ - **metrics-collector** -- Requires `MetricsCollector` class
138
+
139
+ ## Estimated Time
140
+
141
+ 2 hours
@@ -0,0 +1,179 @@
1
+ # Task: ObsLoggingMiddleware with Stack-Based Timing
2
+
3
+ ## Goal
4
+
5
+ Implement `ObsLoggingMiddleware` that extends the `Middleware` base class to provide structured logging of module call lifecycle events (start, complete, error) with stack-based `performance.now()` timing and configurable input/output logging. Delegates all log output to a `ContextLogger` instance.
6
+
7
+ ## Files Involved
8
+
9
+ - `src/observability/context-logger.ts` -- ObsLoggingMiddleware class
10
+ - `src/middleware/base.ts` -- Middleware base class (dependency)
11
+ - `src/context.ts` -- Context class with shared `data` record (dependency)
12
+ - `tests/observability/test-context-logger.test.ts` -- Unit tests for ObsLoggingMiddleware
13
+
14
+ ## Steps (TDD)
15
+
16
+ ### 1. Write failing tests for before/after lifecycle
17
+
18
+ ```typescript
19
+ describe('ObsLoggingMiddleware', () => {
20
+ it('logs before and after', () => {
21
+ const { output, lines } = createBufferOutput();
22
+ const logger = new ContextLogger({ output });
23
+ const mw = new ObsLoggingMiddleware({ logger });
24
+ const ctx = Context.create();
25
+
26
+ mw.before('mod.a', { name: 'Alice' }, ctx);
27
+ mw.after('mod.a', { name: 'Alice' }, { result: 'ok' }, ctx);
28
+
29
+ expect(lines).toHaveLength(2);
30
+ const before = JSON.parse(lines[0]);
31
+ const after = JSON.parse(lines[1]);
32
+ expect(before.message).toBe('Module call started');
33
+ expect(before.extra.module_id).toBe('mod.a');
34
+ expect(after.message).toBe('Module call completed');
35
+ expect(after.extra.duration_ms).toBeDefined();
36
+ });
37
+ });
38
+ ```
39
+
40
+ ### 2. Write failing tests for error lifecycle
41
+
42
+ ```typescript
43
+ it('logs onError', () => {
44
+ const { output, lines } = createBufferOutput();
45
+ const logger = new ContextLogger({ output });
46
+ const mw = new ObsLoggingMiddleware({ logger });
47
+ const ctx = Context.create();
48
+
49
+ mw.before('mod.a', {}, ctx);
50
+ mw.onError('mod.a', {}, new Error('boom'), ctx);
51
+
52
+ expect(lines).toHaveLength(2);
53
+ const errorLog = JSON.parse(lines[1]);
54
+ expect(errorLog.message).toBe('Module call failed');
55
+ expect(errorLog.extra.error_type).toBe('Error');
56
+ expect(errorLog.extra.duration_ms).toBeDefined();
57
+ });
58
+ ```
59
+
60
+ ### 3. Implement constructor with configurable options
61
+
62
+ ```typescript
63
+ export class ObsLoggingMiddleware extends Middleware {
64
+ private _logger: ContextLogger;
65
+ private _logInputs: boolean;
66
+ private _logOutputs: boolean;
67
+
68
+ constructor(options?: {
69
+ logger?: ContextLogger;
70
+ logInputs?: boolean; // default: true
71
+ logOutputs?: boolean; // default: true
72
+ }) {
73
+ super();
74
+ this._logger = options?.logger ?? new ContextLogger({ name: 'apcore.obs_logging' });
75
+ this._logInputs = options?.logInputs ?? true;
76
+ this._logOutputs = options?.logOutputs ?? true;
77
+ }
78
+ }
79
+ ```
80
+
81
+ ### 4. Implement before() with stack-based timing and start log
82
+
83
+ ```typescript
84
+ override before(
85
+ moduleId: string,
86
+ inputs: Record<string, unknown>,
87
+ context: Context,
88
+ ): null {
89
+ const starts = (context.data['_obs_logging_starts'] as number[]) ?? [];
90
+ starts.push(performance.now());
91
+ context.data['_obs_logging_starts'] = starts;
92
+
93
+ const extra: Record<string, unknown> = {
94
+ module_id: moduleId,
95
+ caller_id: context.callerId,
96
+ };
97
+ if (this._logInputs) {
98
+ extra['inputs'] = context.redactedInputs ?? inputs;
99
+ }
100
+ this._logger.info('Module call started', extra);
101
+ return null;
102
+ }
103
+ ```
104
+
105
+ Note: When `logInputs` is true, the middleware prefers `context.redactedInputs` (already sanitized by the executor's redaction step) over raw inputs.
106
+
107
+ ### 5. Implement after() with completion log and duration
108
+
109
+ ```typescript
110
+ override after(
111
+ moduleId: string,
112
+ _inputs: Record<string, unknown>,
113
+ output: Record<string, unknown>,
114
+ context: Context,
115
+ ): null {
116
+ const starts = context.data['_obs_logging_starts'] as number[];
117
+ const startTime = starts.pop()!;
118
+ const durationMs = performance.now() - startTime;
119
+
120
+ const extra: Record<string, unknown> = {
121
+ module_id: moduleId,
122
+ duration_ms: durationMs,
123
+ };
124
+ if (this._logOutputs) {
125
+ extra['output'] = output;
126
+ }
127
+ this._logger.info('Module call completed', extra);
128
+ return null;
129
+ }
130
+ ```
131
+
132
+ Duration is kept in milliseconds (not converted to seconds like MetricsMiddleware) since this is for human-readable log output, not Prometheus metrics.
133
+
134
+ ### 6. Implement onError() with error details
135
+
136
+ ```typescript
137
+ override onError(
138
+ moduleId: string,
139
+ _inputs: Record<string, unknown>,
140
+ error: Error,
141
+ context: Context,
142
+ ): null {
143
+ const starts = context.data['_obs_logging_starts'] as number[];
144
+ const startTime = starts.pop()!;
145
+ const durationMs = performance.now() - startTime;
146
+
147
+ this._logger.error('Module call failed', {
148
+ module_id: moduleId,
149
+ duration_ms: durationMs,
150
+ error_type: error.constructor.name,
151
+ error_message: String(error),
152
+ });
153
+ return null;
154
+ }
155
+ ```
156
+
157
+ ### 7. Run tests and verify all pass
158
+
159
+ ## Acceptance Criteria
160
+
161
+ - [x] `ObsLoggingMiddleware` extends `Middleware` and accepts optional `logger`, `logInputs`, `logOutputs`
162
+ - [x] Defaults to a `ContextLogger` named `apcore.obs_logging` if no logger provided
163
+ - [x] `before()` pushes `performance.now()` onto `context.data['_obs_logging_starts']` stack
164
+ - [x] `before()` emits "Module call started" at info level with module_id and caller_id
165
+ - [x] `before()` includes `context.redactedInputs` (or raw inputs) when `logInputs` is true
166
+ - [x] `after()` pops start time, computes duration in milliseconds, emits "Module call completed"
167
+ - [x] `after()` includes output when `logOutputs` is true
168
+ - [x] `onError()` pops start time, computes duration, emits "Module call failed" at error level
169
+ - [x] Error log includes `error_type` (constructor name) and `error_message` (stringified error)
170
+ - [x] Stack-based timing correctly handles nested module calls
171
+ - [x] All tests pass with `vitest`
172
+
173
+ ## Dependencies
174
+
175
+ - **context-logger** -- Requires `ContextLogger` class
176
+
177
+ ## Estimated Time
178
+
179
+ 2 hours
@@ -0,0 +1,120 @@
1
+ # Task: Span Interface and createSpan() Factory
2
+
3
+ ## Goal
4
+
5
+ Define the `Span` interface representing a single trace span and implement the `createSpan()` factory function that produces span instances with auto-generated span IDs. Also define the `SpanExporter` interface that all exporter implementations must satisfy.
6
+
7
+ ## Files Involved
8
+
9
+ - `src/observability/tracing.ts` -- Span interface, createSpan() factory, SpanExporter interface
10
+ - `tests/observability/test-tracing.test.ts` -- Unit tests for span creation
11
+
12
+ ## Steps (TDD)
13
+
14
+ ### 1. Write failing tests for Span creation
15
+
16
+ ```typescript
17
+ import { describe, it, expect } from 'vitest';
18
+ import { createSpan } from '../../src/observability/tracing.js';
19
+
20
+ describe('Span', () => {
21
+ it('createSpan creates span with defaults', () => {
22
+ const span = createSpan({
23
+ traceId: 'trace-1',
24
+ name: 'test.span',
25
+ startTime: 100,
26
+ });
27
+ expect(span.traceId).toBe('trace-1');
28
+ expect(span.name).toBe('test.span');
29
+ expect(span.startTime).toBe(100);
30
+ expect(span.spanId).toBeDefined();
31
+ expect(span.spanId).toHaveLength(16); // randomBytes(8).toString('hex')
32
+ expect(span.parentSpanId).toBeNull();
33
+ expect(span.status).toBe('ok');
34
+ expect(span.endTime).toBeNull();
35
+ expect(span.events).toEqual([]);
36
+ expect(span.attributes).toEqual({});
37
+ });
38
+
39
+ it('createSpan accepts custom spanId and parentSpanId', () => {
40
+ const span = createSpan({
41
+ traceId: 'trace-2',
42
+ name: 'child.span',
43
+ startTime: 200,
44
+ spanId: 'custom-span-id-01',
45
+ parentSpanId: 'parent-span-0001',
46
+ attributes: { moduleId: 'mod.a' },
47
+ });
48
+ expect(span.spanId).toBe('custom-span-id-01');
49
+ expect(span.parentSpanId).toBe('parent-span-0001');
50
+ expect(span.attributes['moduleId']).toBe('mod.a');
51
+ });
52
+ });
53
+ ```
54
+
55
+ ### 2. Define the Span interface
56
+
57
+ Define a TypeScript `interface Span` with the following fields:
58
+ - `traceId: string` -- trace identifier linking related spans
59
+ - `name: string` -- operation name (e.g., `apcore.module.execute`)
60
+ - `startTime: number` -- `performance.now()` timestamp in milliseconds
61
+ - `spanId: string` -- unique 16-hex-char identifier
62
+ - `parentSpanId: string | null` -- parent span for nesting, null for root spans
63
+ - `attributes: Record<string, unknown>` -- key-value metadata
64
+ - `endTime: number | null` -- set when span completes, null while active
65
+ - `status: string` -- `'ok'` or `'error'`
66
+ - `events: Array<Record<string, unknown>>` -- timeline annotations
67
+
68
+ ### 3. Implement createSpan() factory
69
+
70
+ ```typescript
71
+ import { randomBytes } from 'node:crypto';
72
+
73
+ export function createSpan(options: {
74
+ traceId: string;
75
+ name: string;
76
+ startTime: number;
77
+ spanId?: string;
78
+ parentSpanId?: string | null;
79
+ attributes?: Record<string, unknown>;
80
+ }): Span {
81
+ return {
82
+ traceId: options.traceId,
83
+ name: options.name,
84
+ startTime: options.startTime,
85
+ spanId: options.spanId ?? randomBytes(8).toString('hex'),
86
+ parentSpanId: options.parentSpanId ?? null,
87
+ attributes: options.attributes ?? {},
88
+ endTime: null,
89
+ status: 'ok',
90
+ events: [],
91
+ };
92
+ }
93
+ ```
94
+
95
+ ### 4. Define the SpanExporter interface
96
+
97
+ ```typescript
98
+ export interface SpanExporter {
99
+ export(span: Span): void;
100
+ }
101
+ ```
102
+
103
+ ### 5. Run tests and verify all pass
104
+
105
+ ## Acceptance Criteria
106
+
107
+ - [x] `Span` interface defines all 9 fields with correct types
108
+ - [x] `createSpan()` generates 16-character hex span IDs via `randomBytes(8).toString('hex')`
109
+ - [x] Default values: `parentSpanId: null`, `attributes: {}`, `endTime: null`, `status: 'ok'`, `events: []`
110
+ - [x] Custom `spanId`, `parentSpanId`, and `attributes` can be provided via options
111
+ - [x] `SpanExporter` interface declares a single `export(span: Span): void` method
112
+ - [x] All tests pass with `vitest`
113
+
114
+ ## Dependencies
115
+
116
+ - None (foundational task)
117
+
118
+ ## Estimated Time
119
+
120
+ 1 hour