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,304 @@
|
|
|
1
|
+
# Task: LoggingMiddleware with Logger Interface and Timing
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement a `LoggingMiddleware` that provides structured logging for every module call lifecycle phase (before, after, onError). It uses a pluggable `Logger` interface with `info()` and `error()` methods, defaults to `console.log`/`console.error`, and measures execution duration with `performance.now()`. The middleware stores its start timestamp on `context.data['_logging_mw_start']` and computes duration in the `after()` phase. Logging of inputs, outputs, and errors is individually configurable.
|
|
6
|
+
|
|
7
|
+
## Files Involved
|
|
8
|
+
|
|
9
|
+
| File | Action |
|
|
10
|
+
|------|--------|
|
|
11
|
+
| `src/middleware/logging.ts` | Create -- LoggingMiddleware class and Logger interface |
|
|
12
|
+
| `src/middleware/base.ts` | Read -- Middleware base class (dependency) |
|
|
13
|
+
| `tests/test-middleware.test.ts` | Extend -- Unit tests for logging middleware (or separate test file) |
|
|
14
|
+
|
|
15
|
+
## Steps (TDD)
|
|
16
|
+
|
|
17
|
+
### Step 1: Define the Logger interface
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
export interface Logger {
|
|
21
|
+
info(message: string, extra?: Record<string, unknown>): void;
|
|
22
|
+
error(message: string, extra?: Record<string, unknown>): void;
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Step 2: Implement the default logger
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
const defaultLogger: Logger = {
|
|
30
|
+
info(message: string, extra?: Record<string, unknown>) {
|
|
31
|
+
console.log(message, extra ?? '');
|
|
32
|
+
},
|
|
33
|
+
error(message: string, extra?: Record<string, unknown>) {
|
|
34
|
+
console.error(message, extra ?? '');
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Step 3: Write failing tests for before() logging and timing
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
43
|
+
import { LoggingMiddleware, Logger } from '../src/middleware/logging.js';
|
|
44
|
+
import { Context, createIdentity } from '../src/context.js';
|
|
45
|
+
|
|
46
|
+
function makeContext(): Context {
|
|
47
|
+
return Context.create(null, createIdentity('test-user'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('LoggingMiddleware', () => {
|
|
51
|
+
it('before() stores start time on context.data and logs START message', () => {
|
|
52
|
+
const logs: Array<{ message: string; extra: Record<string, unknown> }> = [];
|
|
53
|
+
const mockLogger: Logger = {
|
|
54
|
+
info(message, extra) { logs.push({ message, extra: extra ?? {} }); },
|
|
55
|
+
error(message, extra) { logs.push({ message, extra: extra ?? {} }); },
|
|
56
|
+
};
|
|
57
|
+
const mw = new LoggingMiddleware({ logger: mockLogger });
|
|
58
|
+
const ctx = makeContext();
|
|
59
|
+
|
|
60
|
+
const result = mw.before('mod.test', { x: 1 }, ctx);
|
|
61
|
+
|
|
62
|
+
expect(result).toBeNull();
|
|
63
|
+
expect(ctx.data['_logging_mw_start']).toBeTypeOf('number');
|
|
64
|
+
expect(logs).toHaveLength(1);
|
|
65
|
+
expect(logs[0].message).toContain('START mod.test');
|
|
66
|
+
expect(logs[0].extra['moduleId']).toBe('mod.test');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Step 4: Implement before() with performance.now() timing
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
override before(
|
|
75
|
+
moduleId: string,
|
|
76
|
+
inputs: Record<string, unknown>,
|
|
77
|
+
context: Context,
|
|
78
|
+
): null {
|
|
79
|
+
context.data['_logging_mw_start'] = performance.now();
|
|
80
|
+
|
|
81
|
+
if (this._logInputs) {
|
|
82
|
+
const redacted = context.redactedInputs ?? inputs;
|
|
83
|
+
this._logger.info(`[${context.traceId}] START ${moduleId}`, {
|
|
84
|
+
traceId: context.traceId,
|
|
85
|
+
moduleId,
|
|
86
|
+
callerId: context.callerId,
|
|
87
|
+
inputs: redacted,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Key design choice: `performance.now()` provides sub-millisecond resolution and is available in Node.js 18+ globally. It is preferable to `Date.now()` for duration measurement because it is monotonic and not affected by system clock adjustments.
|
|
96
|
+
|
|
97
|
+
### Step 5: Write failing tests for after() logging with duration
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
it('after() logs END message with duration', () => {
|
|
101
|
+
const logs: Array<{ message: string; extra: Record<string, unknown> }> = [];
|
|
102
|
+
const mockLogger: Logger = {
|
|
103
|
+
info(message, extra) { logs.push({ message, extra: extra ?? {} }); },
|
|
104
|
+
error() {},
|
|
105
|
+
};
|
|
106
|
+
const mw = new LoggingMiddleware({ logger: mockLogger });
|
|
107
|
+
const ctx = makeContext();
|
|
108
|
+
|
|
109
|
+
mw.before('mod.test', { x: 1 }, ctx);
|
|
110
|
+
const result = mw.after('mod.test', { x: 1 }, { y: 2 }, ctx);
|
|
111
|
+
|
|
112
|
+
expect(result).toBeNull();
|
|
113
|
+
expect(logs).toHaveLength(2); // START + END
|
|
114
|
+
expect(logs[1].message).toContain('END mod.test');
|
|
115
|
+
expect(logs[1].extra['durationMs']).toBeTypeOf('number');
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Step 6: Implement after() with duration calculation
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
override after(
|
|
123
|
+
moduleId: string,
|
|
124
|
+
_inputs: Record<string, unknown>,
|
|
125
|
+
output: Record<string, unknown>,
|
|
126
|
+
context: Context,
|
|
127
|
+
): null {
|
|
128
|
+
const startTime = (context.data['_logging_mw_start'] as number) ?? performance.now();
|
|
129
|
+
const durationMs = performance.now() - startTime;
|
|
130
|
+
|
|
131
|
+
if (this._logOutputs) {
|
|
132
|
+
this._logger.info(
|
|
133
|
+
`[${context.traceId}] END ${moduleId} (${durationMs.toFixed(2)}ms)`,
|
|
134
|
+
{
|
|
135
|
+
traceId: context.traceId,
|
|
136
|
+
moduleId,
|
|
137
|
+
durationMs,
|
|
138
|
+
output,
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Step 7: Write failing tests for onError() logging
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
it('onError() logs ERROR message with redacted inputs', () => {
|
|
151
|
+
const logs: Array<{ message: string; extra: Record<string, unknown> }> = [];
|
|
152
|
+
const mockLogger: Logger = {
|
|
153
|
+
info() {},
|
|
154
|
+
error(message, extra) { logs.push({ message, extra: extra ?? {} }); },
|
|
155
|
+
};
|
|
156
|
+
const mw = new LoggingMiddleware({ logger: mockLogger });
|
|
157
|
+
const ctx = makeContext();
|
|
158
|
+
|
|
159
|
+
const result = mw.onError('mod.test', { x: 1 }, new Error('boom'), ctx);
|
|
160
|
+
|
|
161
|
+
expect(result).toBeNull();
|
|
162
|
+
expect(logs).toHaveLength(1);
|
|
163
|
+
expect(logs[0].message).toContain('ERROR mod.test');
|
|
164
|
+
expect(logs[0].extra['error']).toBe('Error: boom');
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Step 8: Implement onError()
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
override onError(
|
|
172
|
+
moduleId: string,
|
|
173
|
+
inputs: Record<string, unknown>,
|
|
174
|
+
error: Error,
|
|
175
|
+
context: Context,
|
|
176
|
+
): null {
|
|
177
|
+
if (this._logErrors) {
|
|
178
|
+
const redacted = context.redactedInputs ?? inputs;
|
|
179
|
+
this._logger.error(`[${context.traceId}] ERROR ${moduleId}: ${error}`, {
|
|
180
|
+
traceId: context.traceId,
|
|
181
|
+
moduleId,
|
|
182
|
+
error: String(error),
|
|
183
|
+
inputs: redacted,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Step 9: Write tests for configuration flags
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
it('respects logInputs=false', () => {
|
|
195
|
+
const logs: string[] = [];
|
|
196
|
+
const mockLogger: Logger = {
|
|
197
|
+
info(msg) { logs.push(msg); },
|
|
198
|
+
error() {},
|
|
199
|
+
};
|
|
200
|
+
const mw = new LoggingMiddleware({ logger: mockLogger, logInputs: false });
|
|
201
|
+
const ctx = makeContext();
|
|
202
|
+
mw.before('mod.test', { x: 1 }, ctx);
|
|
203
|
+
expect(logs).toHaveLength(0);
|
|
204
|
+
// But timing is still stored
|
|
205
|
+
expect(ctx.data['_logging_mw_start']).toBeTypeOf('number');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('respects logOutputs=false', () => {
|
|
209
|
+
const logs: string[] = [];
|
|
210
|
+
const mockLogger: Logger = {
|
|
211
|
+
info(msg) { logs.push(msg); },
|
|
212
|
+
error() {},
|
|
213
|
+
};
|
|
214
|
+
const mw = new LoggingMiddleware({ logger: mockLogger, logOutputs: false });
|
|
215
|
+
const ctx = makeContext();
|
|
216
|
+
mw.before('mod.test', {}, ctx);
|
|
217
|
+
mw.after('mod.test', {}, { y: 1 }, ctx);
|
|
218
|
+
expect(logs).toHaveLength(1); // Only START, no END
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('respects logErrors=false', () => {
|
|
222
|
+
const logs: string[] = [];
|
|
223
|
+
const mockLogger: Logger = {
|
|
224
|
+
info() {},
|
|
225
|
+
error(msg) { logs.push(msg); },
|
|
226
|
+
};
|
|
227
|
+
const mw = new LoggingMiddleware({ logger: mockLogger, logErrors: false });
|
|
228
|
+
const ctx = makeContext();
|
|
229
|
+
mw.onError('mod.test', {}, new Error('boom'), ctx);
|
|
230
|
+
expect(logs).toHaveLength(0);
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Step 10: Implement constructor with options
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
export class LoggingMiddleware extends Middleware {
|
|
238
|
+
private _logger: Logger;
|
|
239
|
+
private _logInputs: boolean;
|
|
240
|
+
private _logOutputs: boolean;
|
|
241
|
+
private _logErrors: boolean;
|
|
242
|
+
|
|
243
|
+
constructor(options?: {
|
|
244
|
+
logger?: Logger;
|
|
245
|
+
logInputs?: boolean;
|
|
246
|
+
logOutputs?: boolean;
|
|
247
|
+
logErrors?: boolean;
|
|
248
|
+
}) {
|
|
249
|
+
super();
|
|
250
|
+
this._logger = options?.logger ?? defaultLogger;
|
|
251
|
+
this._logInputs = options?.logInputs ?? true;
|
|
252
|
+
this._logOutputs = options?.logOutputs ?? true;
|
|
253
|
+
this._logErrors = options?.logErrors ?? true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Step 11: Write test for redactedInputs usage
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
it('uses context.redactedInputs when available', () => {
|
|
262
|
+
const logs: Array<{ extra: Record<string, unknown> }> = [];
|
|
263
|
+
const mockLogger: Logger = {
|
|
264
|
+
info(_msg, extra) { logs.push({ extra: extra ?? {} }); },
|
|
265
|
+
error() {},
|
|
266
|
+
};
|
|
267
|
+
const mw = new LoggingMiddleware({ logger: mockLogger });
|
|
268
|
+
const ctx = makeContext();
|
|
269
|
+
(ctx as any).redactedInputs = { x: '***' };
|
|
270
|
+
|
|
271
|
+
mw.before('mod.test', { x: 'secret' }, ctx);
|
|
272
|
+
|
|
273
|
+
expect(logs[0].extra['inputs']).toEqual({ x: '***' });
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Step 12: Run all tests and confirm green
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
npx vitest run tests/test-middleware.test.ts
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Acceptance Criteria
|
|
284
|
+
|
|
285
|
+
- [x] `Logger` interface is exported with `info(message, extra?)` and `error(message, extra?)` methods
|
|
286
|
+
- [x] Default logger delegates to `console.log` and `console.error`
|
|
287
|
+
- [x] `LoggingMiddleware` extends `Middleware`
|
|
288
|
+
- [x] `before()` stores `performance.now()` on `context.data['_logging_mw_start']` and logs START
|
|
289
|
+
- [x] `after()` computes duration from stored start time and logs END with `durationMs`
|
|
290
|
+
- [x] `onError()` logs ERROR with trace ID, module ID, and stringified error
|
|
291
|
+
- [x] Uses `context.redactedInputs` when available instead of raw inputs
|
|
292
|
+
- [x] `logInputs`, `logOutputs`, `logErrors` flags default to `true` and are individually configurable
|
|
293
|
+
- [x] All hooks return `null` (logging middleware does not transform inputs/outputs)
|
|
294
|
+
- [x] Duration is formatted with `.toFixed(2)` in the log message
|
|
295
|
+
- [x] All tests pass with `vitest`
|
|
296
|
+
|
|
297
|
+
## Dependencies
|
|
298
|
+
|
|
299
|
+
- `src/middleware/base.ts` -- `Middleware` base class (task: base)
|
|
300
|
+
- `src/context.ts` -- `Context` class with `traceId`, `callerId`, `data`, `redactedInputs` properties
|
|
301
|
+
|
|
302
|
+
## Estimated Time
|
|
303
|
+
|
|
304
|
+
2 hours
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Task: MiddlewareManager with Onion-Model Execution
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Implement the `MiddlewareManager` class that manages middleware registration and executes the onion-model pipeline. The manager provides `add()`, `remove()` (by reference identity), and `snapshot()` for list management. It exposes `executeBefore()` (forward order), `executeAfter()` (reverse order), and `executeOnError()` (reverse over executed subset, first non-null recovery wins). Also implement `MiddlewareChainError` extending `ModuleError` to wrap failures during the `before()` phase with tracking of which middlewares have already executed.
|
|
6
|
+
|
|
7
|
+
## Files Involved
|
|
8
|
+
|
|
9
|
+
| File | Action |
|
|
10
|
+
|------|--------|
|
|
11
|
+
| `src/middleware/manager.ts` | Create -- MiddlewareManager and MiddlewareChainError |
|
|
12
|
+
| `src/errors.ts` | Read -- ModuleError base class (dependency) |
|
|
13
|
+
| `tests/test-middleware-manager.test.ts` | Create -- Unit tests for manager and chain error |
|
|
14
|
+
|
|
15
|
+
## Steps (TDD)
|
|
16
|
+
|
|
17
|
+
### Step 1: Write failing tests for add/remove/snapshot
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { Middleware } from '../src/middleware/base.js';
|
|
22
|
+
import { MiddlewareManager } from '../src/middleware/manager.js';
|
|
23
|
+
|
|
24
|
+
describe('MiddlewareManager', () => {
|
|
25
|
+
it('starts empty', () => {
|
|
26
|
+
const mgr = new MiddlewareManager();
|
|
27
|
+
expect(mgr.snapshot()).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('add and snapshot', () => {
|
|
31
|
+
const mgr = new MiddlewareManager();
|
|
32
|
+
mgr.add(new Middleware());
|
|
33
|
+
mgr.add(new Middleware());
|
|
34
|
+
expect(mgr.snapshot()).toHaveLength(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('snapshot returns a copy', () => {
|
|
38
|
+
const mgr = new MiddlewareManager();
|
|
39
|
+
mgr.add(new Middleware());
|
|
40
|
+
const snap = mgr.snapshot();
|
|
41
|
+
snap.pop();
|
|
42
|
+
expect(mgr.snapshot()).toHaveLength(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('remove by identity', () => {
|
|
46
|
+
const mgr = new MiddlewareManager();
|
|
47
|
+
const mw1 = new Middleware();
|
|
48
|
+
const mw2 = new Middleware();
|
|
49
|
+
mgr.add(mw1);
|
|
50
|
+
mgr.add(mw2);
|
|
51
|
+
expect(mgr.remove(mw1)).toBe(true);
|
|
52
|
+
expect(mgr.snapshot()).toEqual([mw2]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('remove returns false when not found', () => {
|
|
56
|
+
const mgr = new MiddlewareManager();
|
|
57
|
+
expect(mgr.remove(new Middleware())).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Step 2: Implement add, remove, snapshot
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { Middleware } from './base.js';
|
|
66
|
+
|
|
67
|
+
export class MiddlewareManager {
|
|
68
|
+
private _middlewares: Middleware[] = [];
|
|
69
|
+
|
|
70
|
+
add(middleware: Middleware): void {
|
|
71
|
+
this._middlewares.push(middleware);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
remove(middleware: Middleware): boolean {
|
|
75
|
+
for (let i = 0; i < this._middlewares.length; i++) {
|
|
76
|
+
if (this._middlewares[i] === middleware) {
|
|
77
|
+
this._middlewares.splice(i, 1);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
snapshot(): Middleware[] {
|
|
85
|
+
return [...this._middlewares];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
No thread locking is needed -- Node.js is single-threaded. The Python implementation uses `threading.Lock`; in TypeScript, `snapshot()` returning a shallow copy is sufficient to guard against mutations during iteration.
|
|
91
|
+
|
|
92
|
+
### Step 3: Write failing tests for executeBefore (forward order)
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
it('executeBefore runs in forward order', () => {
|
|
96
|
+
const mgr = new MiddlewareManager();
|
|
97
|
+
mgr.add(new TaggingMiddleware('A'));
|
|
98
|
+
mgr.add(new TaggingMiddleware('B'));
|
|
99
|
+
mgr.add(new TaggingMiddleware('C'));
|
|
100
|
+
const ctx = makeContext();
|
|
101
|
+
const [result, executed] = mgr.executeBefore('mod.test', { trail: '' }, ctx);
|
|
102
|
+
expect(result['trail']).toBe('ABC');
|
|
103
|
+
expect(executed).toHaveLength(3);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('executeBefore passes original inputs when all return null', () => {
|
|
107
|
+
const mgr = new MiddlewareManager();
|
|
108
|
+
mgr.add(new Middleware());
|
|
109
|
+
const ctx = makeContext();
|
|
110
|
+
const [result] = mgr.executeBefore('mod.test', { x: 42 }, ctx);
|
|
111
|
+
expect(result).toEqual({ x: 42 });
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Step 4: Implement executeBefore
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
executeBefore(
|
|
119
|
+
moduleId: string,
|
|
120
|
+
inputs: Record<string, unknown>,
|
|
121
|
+
context: Context,
|
|
122
|
+
): [Record<string, unknown>, Middleware[]] {
|
|
123
|
+
let currentInputs = inputs;
|
|
124
|
+
const executedMiddlewares: Middleware[] = [];
|
|
125
|
+
const middlewares = this.snapshot();
|
|
126
|
+
|
|
127
|
+
for (const mw of middlewares) {
|
|
128
|
+
executedMiddlewares.push(mw);
|
|
129
|
+
try {
|
|
130
|
+
const result = mw.before(moduleId, currentInputs, context);
|
|
131
|
+
if (result !== null) {
|
|
132
|
+
currentInputs = result;
|
|
133
|
+
}
|
|
134
|
+
} catch (e) {
|
|
135
|
+
throw new MiddlewareChainError(e as Error, executedMiddlewares);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return [currentInputs, executedMiddlewares];
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Step 5: Write failing tests for executeAfter (reverse order)
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
it('executeAfter runs in reverse order', () => {
|
|
147
|
+
const mgr = new MiddlewareManager();
|
|
148
|
+
mgr.add(new TaggingMiddleware('A'));
|
|
149
|
+
mgr.add(new TaggingMiddleware('B'));
|
|
150
|
+
mgr.add(new TaggingMiddleware('C'));
|
|
151
|
+
const ctx = makeContext();
|
|
152
|
+
const result = mgr.executeAfter('mod.test', {}, { trail: '' }, ctx);
|
|
153
|
+
expect(result['trail']).toBe('CBA');
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Step 6: Implement executeAfter
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
executeAfter(
|
|
161
|
+
moduleId: string,
|
|
162
|
+
inputs: Record<string, unknown>,
|
|
163
|
+
output: Record<string, unknown>,
|
|
164
|
+
context: Context,
|
|
165
|
+
): Record<string, unknown> {
|
|
166
|
+
let currentOutput = output;
|
|
167
|
+
const middlewares = this.snapshot();
|
|
168
|
+
|
|
169
|
+
for (let i = middlewares.length - 1; i >= 0; i--) {
|
|
170
|
+
const result = middlewares[i].after(moduleId, inputs, currentOutput, context);
|
|
171
|
+
if (result !== null) {
|
|
172
|
+
currentOutput = result;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return currentOutput;
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Step 7: Write failing tests for executeOnError (reverse, first recovery wins, errors swallowed)
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
it('executeOnError returns first non-null recovery (reverse order)', () => {
|
|
184
|
+
const mgr = new MiddlewareManager();
|
|
185
|
+
const mwA = new RecoveringMiddleware({ recovered: 'A' });
|
|
186
|
+
const mwB = new RecoveringMiddleware({ recovered: 'B' });
|
|
187
|
+
mgr.add(mwA);
|
|
188
|
+
mgr.add(mwB);
|
|
189
|
+
const ctx = makeContext();
|
|
190
|
+
const result = mgr.executeOnError('mod.test', {}, new Error('oops'), ctx, [mwA, mwB]);
|
|
191
|
+
expect(result).toEqual({ recovered: 'B' });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('executeOnError returns null when no recovery', () => {
|
|
195
|
+
const mgr = new MiddlewareManager();
|
|
196
|
+
const mw = new Middleware();
|
|
197
|
+
mgr.add(mw);
|
|
198
|
+
const ctx = makeContext();
|
|
199
|
+
const result = mgr.executeOnError('mod.test', {}, new Error('oops'), ctx, [mw]);
|
|
200
|
+
expect(result).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('executeOnError swallows errors in onError handlers', () => {
|
|
204
|
+
// ThrowingOnError middleware throws inside onError()
|
|
205
|
+
// RecoveringMiddleware before it should still provide recovery
|
|
206
|
+
const result = mgr.executeOnError('mod.test', {}, new Error('original'), ctx, [mwRecover, mwThrow]);
|
|
207
|
+
expect(result).toEqual({ safe: true });
|
|
208
|
+
});
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Step 8: Implement executeOnError
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
executeOnError(
|
|
215
|
+
moduleId: string,
|
|
216
|
+
inputs: Record<string, unknown>,
|
|
217
|
+
error: Error,
|
|
218
|
+
context: Context,
|
|
219
|
+
executedMiddlewares: Middleware[],
|
|
220
|
+
): Record<string, unknown> | null {
|
|
221
|
+
for (let i = executedMiddlewares.length - 1; i >= 0; i--) {
|
|
222
|
+
try {
|
|
223
|
+
const result = executedMiddlewares[i].onError(moduleId, inputs, error, context);
|
|
224
|
+
if (result !== null) {
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
// Swallow errors in onError handlers
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Step 9: Write failing test for MiddlewareChainError
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
it('MiddlewareChainError wraps before() failure', () => {
|
|
240
|
+
class FailingBefore extends Middleware {
|
|
241
|
+
override before(): Record<string, unknown> | null {
|
|
242
|
+
throw new Error('before exploded');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const mgr = new MiddlewareManager();
|
|
246
|
+
mgr.add(new TaggingMiddleware('A'));
|
|
247
|
+
mgr.add(new FailingBefore());
|
|
248
|
+
const ctx = makeContext();
|
|
249
|
+
|
|
250
|
+
let caught: MiddlewareChainError | undefined;
|
|
251
|
+
try {
|
|
252
|
+
mgr.executeBefore('mod.test', { trail: '' }, ctx);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
caught = e as MiddlewareChainError;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
expect(caught).toBeInstanceOf(MiddlewareChainError);
|
|
258
|
+
expect(caught!.original.message).toBe('before exploded');
|
|
259
|
+
expect(caught!.executedMiddlewares).toHaveLength(2);
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Step 10: Implement MiddlewareChainError
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
import { ModuleError } from '../errors.js';
|
|
267
|
+
|
|
268
|
+
export class MiddlewareChainError extends ModuleError {
|
|
269
|
+
readonly original: Error;
|
|
270
|
+
readonly executedMiddlewares: Middleware[];
|
|
271
|
+
|
|
272
|
+
constructor(original: Error, executedMiddlewares: Middleware[]) {
|
|
273
|
+
super('MIDDLEWARE_CHAIN_ERROR', String(original));
|
|
274
|
+
this.name = 'MiddlewareChainError';
|
|
275
|
+
this.original = original;
|
|
276
|
+
this.executedMiddlewares = executedMiddlewares;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Key difference from Python: extends `ModuleError` (which extends `Error`), not `Exception`. This integrates with the framework's structured error hierarchy providing `code`, `details`, and `timestamp` properties.
|
|
282
|
+
|
|
283
|
+
### Step 11: Run all tests and confirm green
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
npx vitest run tests/test-middleware-manager.test.ts
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Acceptance Criteria
|
|
290
|
+
|
|
291
|
+
- [x] `MiddlewareManager` is exported from `src/middleware/manager.ts`
|
|
292
|
+
- [x] `add()` appends a middleware to the internal list
|
|
293
|
+
- [x] `remove()` removes by strict reference identity (`===`) and returns `boolean`
|
|
294
|
+
- [x] `snapshot()` returns a shallow copy; mutations to the copy do not affect the internal list
|
|
295
|
+
- [x] `executeBefore()` runs in forward registration order, returns `[transformedInputs, executedMiddlewares]`
|
|
296
|
+
- [x] `executeBefore()` wraps middleware errors in `MiddlewareChainError`
|
|
297
|
+
- [x] `executeAfter()` runs in reverse registration order
|
|
298
|
+
- [x] `executeOnError()` runs in reverse over the executed middlewares subset
|
|
299
|
+
- [x] `executeOnError()` returns the first non-null recovery value
|
|
300
|
+
- [x] `executeOnError()` swallows errors thrown by `onError()` handlers
|
|
301
|
+
- [x] `MiddlewareChainError` extends `ModuleError` with `original` and `executedMiddlewares` properties
|
|
302
|
+
- [x] No thread locking is used (Node.js single-threaded model)
|
|
303
|
+
- [x] All tests pass with `vitest`
|
|
304
|
+
|
|
305
|
+
## Dependencies
|
|
306
|
+
|
|
307
|
+
- `src/middleware/base.ts` -- `Middleware` base class (task: base)
|
|
308
|
+
- `src/errors.ts` -- `ModuleError` base error class
|
|
309
|
+
- `src/context.ts` -- `Context` class (parameter type)
|
|
310
|
+
|
|
311
|
+
## Estimated Time
|
|
312
|
+
|
|
313
|
+
3 hours
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Feature: Observability
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The Observability module provides the three pillars of runtime visibility for apcore: distributed tracing, metrics collection, and structured logging. All three integrate into the executor's middleware pipeline as composable `Middleware` subclasses that use stack-based state in the shared `context.data` record to correctly handle nested module-to-module calls. Tracing produces `Span` objects exported through pluggable `SpanExporter` implementations. Metrics are collected via `MetricsCollector` with Prometheus-compatible text export. Logging is handled by `ContextLogger` with JSON and text output formats, automatic `_secret_`-prefixed key redaction, and a `fromContext()` factory that binds trace/module/caller metadata.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
### Included
|
|
10
|
+
|
|
11
|
+
- `Span` interface and `createSpan()` factory function for trace span creation
|
|
12
|
+
- `SpanExporter` interface with `StdoutExporter` (JSON.stringify to stdout) and `InMemoryExporter` (bounded array with shift() eviction)
|
|
13
|
+
- `TracingMiddleware` with stack-based span management and 4 sampling strategies: `full`, `proportional`, `error_first`, `off`
|
|
14
|
+
- `MetricsCollector` with counter and histogram support, string-encoded composite keys (`name|key1=val1,key2=val2`), Prometheus text format export, and convenience methods (`incrementCalls`, `incrementErrors`, `observeDuration`)
|
|
15
|
+
- `MetricsMiddleware` with stack-based `performance.now()` timing and automatic call/error/duration recording
|
|
16
|
+
- `ContextLogger` with JSON and text output formats, level filtering (trace/debug/info/warn/error/fatal), `_secret_`-prefixed key redaction, and `fromContext()` static factory
|
|
17
|
+
- `ObsLoggingMiddleware` with stack-based timing, configurable input/output logging, and start/complete/error structured log events
|
|
18
|
+
|
|
19
|
+
### Excluded
|
|
20
|
+
|
|
21
|
+
- `OTLPExporter` for OpenTelemetry Protocol export (not yet implemented -- noted as a gap for future work)
|
|
22
|
+
- OpenTelemetry SDK bridge or direct OTel integration
|
|
23
|
+
- Persistent metric storage or time-series database integration
|
|
24
|
+
- Log file rotation or external log shipping
|
|
25
|
+
- Dashboard or alerting configuration
|
|
26
|
+
|
|
27
|
+
## Technology Stack
|
|
28
|
+
|
|
29
|
+
- **TypeScript 5.5+** with strict mode
|
|
30
|
+
- **Node.js >= 18.0.0** with ES Module support (`performance.now()`, `node:crypto`)
|
|
31
|
+
- **vitest** for unit testing
|
|
32
|
+
|
|
33
|
+
## Task Execution Order
|
|
34
|
+
|
|
35
|
+
| # | Task File | Description | Status |
|
|
36
|
+
|---|-----------|-------------|--------|
|
|
37
|
+
| 1 | [span-model](./tasks/span-model.md) | Span interface and createSpan() factory, SpanExporter interface | completed |
|
|
38
|
+
| 2 | [exporters](./tasks/exporters.md) | StdoutExporter and InMemoryExporter implementations | completed |
|
|
39
|
+
| 3 | [tracing-middleware](./tasks/tracing-middleware.md) | TracingMiddleware with stack-based spans and sampling strategies | completed |
|
|
40
|
+
| 4 | [metrics-collector](./tasks/metrics-collector.md) | MetricsCollector with counters, histograms, and Prometheus export | completed |
|
|
41
|
+
| 5 | [metrics-middleware](./tasks/metrics-middleware.md) | MetricsMiddleware with stack-based performance.now() timing | completed |
|
|
42
|
+
| 6 | [context-logger](./tasks/context-logger.md) | ContextLogger with JSON/text formats, redaction, fromContext() | completed |
|
|
43
|
+
| 7 | [obs-logging-middleware](./tasks/obs-logging-middleware.md) | ObsLoggingMiddleware with stack-based timing and structured logs | completed |
|
|
44
|
+
|
|
45
|
+
## Progress
|
|
46
|
+
|
|
47
|
+
| Total | Completed | In Progress | Pending |
|
|
48
|
+
|-------|-----------|-------------|---------|
|
|
49
|
+
| 7 | 7 | 0 | 0 |
|
|
50
|
+
|
|
51
|
+
## Reference Documents
|
|
52
|
+
|
|
53
|
+
- [Observability Feature Specification](../../features/observability.md)
|