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,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
|