argsbarg 6.1.7 → 6.1.9
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/CHANGELOG.md +23 -1
- package/docs/README.md +2 -1
- package/docs/cli-program.md +5 -3
- package/docs/config-schema.md +12 -9
- package/docs/decisions.md +21 -2
- package/docs/http-server.md +3 -1
- package/docs/json-schema-subset.md +38 -12
- package/docs/logging.md +149 -0
- package/docs/output-schema.md +1 -1
- package/examples/full-example/docs/cli-schema.json +18 -18
- package/examples/full-example/docs/cli.md +18 -18
- package/examples/full-example/docs/http.md +10 -0
- package/index.d.ts +84 -14
- package/package.json +3 -2
- package/src/builtins/http.ts +1 -1
- package/src/builtins/mcp.ts +1 -1
- package/src/config/validate.test.ts +45 -1
- package/src/config/validate.ts +138 -203
- package/src/core/types.ts +17 -3
- package/src/docs/http-guide.ts +10 -0
- package/src/headless/tool-call.ts +1 -1
- package/src/hooks/run.ts +7 -2
- package/src/http/server.ts +19 -1
- package/src/index.ts +2 -0
- package/src/log/ecs.test.ts +68 -2
- package/src/log/ecs.ts +109 -14
- package/src/log/emitter.test.ts +95 -0
- package/src/log/emitter.ts +74 -11
- package/src/log/trace.test.ts +60 -0
- package/src/log/trace.ts +62 -0
- package/src/runtime/cli.ts +1 -1
package/src/config/validate.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/*
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
JSON Schema validation for program.appConfig and leaf inputSchema (@cfworker/json-schema).
|
|
3
|
+
CLI value coercion for configure set remains here (comma-separated arrays, booleans, etc.).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { format as jsonSchemaFormats, type Schema, type SchemaDraft, Validator } from "@cfworker/json-schema";
|
|
7
|
+
import { parseCommaList, parseDate, parseDateTime, validateCommaList } from "../core/formats.ts";
|
|
7
8
|
import { isFrameworkConfigKey } from "./bindings.ts";
|
|
8
9
|
|
|
9
10
|
type JsonSchema = Record<string, unknown>;
|
|
@@ -19,248 +20,183 @@ export interface ValidateResult {
|
|
|
19
20
|
errors: string[];
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Validate present keys only — skips root `required` checks (partial writes / bootstrap). */
|
|
30
|
-
export function validateConfigDocumentPartial(data: unknown, rootSchema: JsonSchema): ValidateResult {
|
|
31
|
-
const errors: string[] = [];
|
|
32
|
-
validateValue(data, rootSchema, rootSchema, "$", errors, true);
|
|
33
|
-
return { valid: errors.length === 0, errors };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function validateValue(
|
|
37
|
-
value: unknown,
|
|
38
|
-
schema: JsonSchema,
|
|
39
|
-
root: JsonSchema,
|
|
40
|
-
path: string,
|
|
41
|
-
errors: string[],
|
|
42
|
-
partial: boolean,
|
|
43
|
-
): void {
|
|
44
|
-
const resolved = resolveSchema(schema, root);
|
|
45
|
-
if (!resolved) {
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if ("const" in resolved) {
|
|
50
|
-
if (value !== resolved.const) {
|
|
51
|
-
errors.push(`${path}: must be ${JSON.stringify(resolved.const)}`);
|
|
23
|
+
if (!jsonSchemaFormats["comma-list"]) {
|
|
24
|
+
jsonSchemaFormats["comma-list"] = (value: string) => {
|
|
25
|
+
try {
|
|
26
|
+
validateCommaList(value);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
52
30
|
}
|
|
53
|
-
|
|
54
|
-
|
|
31
|
+
};
|
|
32
|
+
}
|
|
55
33
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
return;
|
|
34
|
+
function dataForSchemaValidation(data: unknown): unknown {
|
|
35
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
36
|
+
return data;
|
|
61
37
|
}
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
for (const branch of anyOf) {
|
|
67
|
-
if (typeof branch !== "object" || branch === null || Array.isArray(branch)) {
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
const branchErrs: string[] = [];
|
|
71
|
-
validateValue(value, branch as JsonSchema, root, path, branchErrs, partial);
|
|
72
|
-
if (branchErrs.length === 0) {
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
branchErrors.push(branchErrs);
|
|
38
|
+
const out: Record<string, unknown> = {};
|
|
39
|
+
for (const [key, value] of Object.entries(data)) {
|
|
40
|
+
if (isFrameworkConfigKey(key)) {
|
|
41
|
+
continue;
|
|
76
42
|
}
|
|
77
|
-
|
|
78
|
-
if (branchErrors.length === 1 && branchErrors[0]?.[0]) {
|
|
79
|
-
errors.push(branchErrors[0][0]);
|
|
80
|
-
}
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const types = normalizeTypes(resolved.type);
|
|
85
|
-
if (types.length > 0 && !matchesAnyType(value, types)) {
|
|
86
|
-
errors.push(`${path}: must be ${types.join(" or ")}`);
|
|
87
|
-
return;
|
|
43
|
+
out[key] = value;
|
|
88
44
|
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
89
47
|
|
|
90
|
-
|
|
91
|
-
|
|
48
|
+
function schemaWithoutRequired(schema: unknown): JsonSchema {
|
|
49
|
+
if (typeof schema !== "object" || schema === null) {
|
|
50
|
+
return schema as JsonSchema;
|
|
92
51
|
}
|
|
93
|
-
if (
|
|
94
|
-
|
|
52
|
+
if (Array.isArray(schema)) {
|
|
53
|
+
return schema.map(schemaWithoutRequired) as unknown as JsonSchema;
|
|
95
54
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
55
|
+
const out: Record<string, unknown> = {};
|
|
56
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
57
|
+
if (key === "required") {
|
|
58
|
+
continue;
|
|
99
59
|
}
|
|
60
|
+
out[key] = schemaWithoutRequired(value);
|
|
100
61
|
}
|
|
101
|
-
|
|
102
|
-
errors.push(`${path}: must be null`);
|
|
103
|
-
}
|
|
104
|
-
if (types.includes("object") || (types.length === 0 && isPlainObject(value))) {
|
|
105
|
-
validateObject(value, resolved, root, path, errors, partial);
|
|
106
|
-
}
|
|
107
|
-
if (types.includes("array") || (types.length === 0 && Array.isArray(value))) {
|
|
108
|
-
validateArray(value, resolved, root, path, errors, partial);
|
|
109
|
-
}
|
|
62
|
+
return out as JsonSchema;
|
|
110
63
|
}
|
|
111
64
|
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const name = decodeURIComponent(ref.slice("#/definitions/".length));
|
|
116
|
-
const definitions = root.definitions;
|
|
117
|
-
if (typeof definitions !== "object" || definitions === null || Array.isArray(definitions)) {
|
|
118
|
-
return schema;
|
|
119
|
-
}
|
|
120
|
-
const target = (definitions as Record<string, unknown>)[name];
|
|
121
|
-
if (typeof target === "object" && target !== null && !Array.isArray(target)) {
|
|
122
|
-
return target as JsonSchema;
|
|
123
|
-
}
|
|
65
|
+
function formatInstancePath(instanceLocation: string): string {
|
|
66
|
+
if (instanceLocation.length === 0 || instanceLocation === "#") {
|
|
67
|
+
return "$";
|
|
124
68
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
function normalizeTypes(type: unknown): string[] {
|
|
129
|
-
if (typeof type === "string") {
|
|
130
|
-
return [type];
|
|
69
|
+
if (instanceLocation.startsWith("#/")) {
|
|
70
|
+
return instanceLocation.slice(2).replace(/\//g, ".");
|
|
131
71
|
}
|
|
132
|
-
|
|
133
|
-
return type.filter((t): t is string => typeof t === "string");
|
|
134
|
-
}
|
|
135
|
-
return [];
|
|
72
|
+
return instanceLocation;
|
|
136
73
|
}
|
|
137
74
|
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (t === "integer" && typeof value === "number" && Number.isInteger(value)) return true;
|
|
144
|
-
if (t === "number" && typeof value === "number") return true;
|
|
145
|
-
if (t === "boolean" && typeof value === "boolean") return true;
|
|
146
|
-
if (t === "string" && typeof value === "string") return true;
|
|
147
|
-
}
|
|
148
|
-
return types.length === 0;
|
|
75
|
+
function formatValidationErrors(errors: { instanceLocation: string; error: string }[]): string[] {
|
|
76
|
+
return errors.map(({ instanceLocation, error }) => {
|
|
77
|
+
const path = formatInstancePath(instanceLocation);
|
|
78
|
+
return `${path}: ${error}`;
|
|
79
|
+
});
|
|
149
80
|
}
|
|
150
81
|
|
|
151
|
-
function
|
|
152
|
-
return
|
|
82
|
+
function decodeJsonPointerSegment(segment: string): string {
|
|
83
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
153
84
|
}
|
|
154
85
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
86
|
+
/** Map a schema `$schema` URI to the @cfworker/json-schema draft (defaults to Draft-07). */
|
|
87
|
+
export function resolveSchemaDraft(schema: JsonSchema): SchemaDraft {
|
|
88
|
+
const $schema = schema.$schema;
|
|
89
|
+
if (typeof $schema !== "string") {
|
|
90
|
+
return "7";
|
|
159
91
|
}
|
|
160
|
-
|
|
161
|
-
|
|
92
|
+
const normalized = $schema.toLowerCase();
|
|
93
|
+
if (normalized.includes("2020-12")) {
|
|
94
|
+
return "2020-12";
|
|
162
95
|
}
|
|
163
|
-
if (
|
|
164
|
-
|
|
96
|
+
if (normalized.includes("2019-09")) {
|
|
97
|
+
return "2019-09";
|
|
165
98
|
}
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
99
|
+
if (normalized.includes("draft-04") || normalized.includes("draft/4")) {
|
|
100
|
+
return "4";
|
|
101
|
+
}
|
|
102
|
+
if (normalized.includes("draft-07") || normalized.includes("draft/7")) {
|
|
103
|
+
return "7";
|
|
104
|
+
}
|
|
105
|
+
return "7";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveJsonPointer(root: JsonSchema, ref: string): unknown {
|
|
109
|
+
if (!ref.startsWith("#/")) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const segments = ref
|
|
113
|
+
.slice(2)
|
|
114
|
+
.split("/")
|
|
115
|
+
.filter((segment) => segment.length > 0)
|
|
116
|
+
.map(decodeJsonPointerSegment);
|
|
117
|
+
let current: unknown = root;
|
|
118
|
+
for (const segment of segments) {
|
|
119
|
+
if (typeof current !== "object" || current === null || Array.isArray(current)) {
|
|
120
|
+
return undefined;
|
|
173
121
|
}
|
|
122
|
+
current = (current as Record<string, unknown>)[segment];
|
|
174
123
|
}
|
|
124
|
+
return current;
|
|
175
125
|
}
|
|
176
126
|
|
|
177
|
-
function
|
|
178
|
-
if (
|
|
179
|
-
errors.push(`${path}: must be number`);
|
|
127
|
+
function attachRootCompanionSchemas(validator: Validator, root: JsonSchema, active: JsonSchema): void {
|
|
128
|
+
if (active === root) {
|
|
180
129
|
return;
|
|
181
130
|
}
|
|
182
|
-
|
|
183
|
-
|
|
131
|
+
const companion: Schema = {};
|
|
132
|
+
if (typeof root.definitions === "object" && root.definitions !== null && !Array.isArray(root.definitions)) {
|
|
133
|
+
companion.definitions = root.definitions;
|
|
184
134
|
}
|
|
185
|
-
if (typeof
|
|
186
|
-
|
|
135
|
+
if (typeof root.$defs === "object" && root.$defs !== null && !Array.isArray(root.$defs)) {
|
|
136
|
+
companion.$defs = root.$defs;
|
|
187
137
|
}
|
|
188
|
-
if (
|
|
189
|
-
|
|
138
|
+
if (Object.keys(companion).length > 0) {
|
|
139
|
+
validator.addSchema(companion);
|
|
190
140
|
}
|
|
191
141
|
}
|
|
192
142
|
|
|
193
|
-
function
|
|
194
|
-
|
|
143
|
+
function validatorForSchema(schema: JsonSchema, root: JsonSchema, partial: boolean): Validator {
|
|
144
|
+
const active = partial ? schemaWithoutRequired(schema) : schema;
|
|
145
|
+
const validator = new Validator(active as Schema, resolveSchemaDraft(root), false);
|
|
146
|
+
attachRootCompanionSchemas(validator, root, active);
|
|
147
|
+
return validator;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validateInstance(
|
|
151
|
+
data: unknown,
|
|
195
152
|
schema: JsonSchema,
|
|
196
153
|
root: JsonSchema,
|
|
197
|
-
path: string,
|
|
198
|
-
errors: string[],
|
|
199
154
|
partial: boolean,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
155
|
+
stripFrameworkKeys: boolean,
|
|
156
|
+
): ValidateResult {
|
|
157
|
+
const validator = validatorForSchema(schema, root, partial);
|
|
158
|
+
const payload = stripFrameworkKeys ? dataForSchemaValidation(data) : data;
|
|
159
|
+
const result = validator.validate(payload);
|
|
160
|
+
if (result.valid) {
|
|
161
|
+
return { valid: true, errors: [] };
|
|
162
|
+
}
|
|
163
|
+
return { valid: false, errors: formatValidationErrors(result.errors) };
|
|
164
|
+
}
|
|
210
165
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
validateValue(value[key], propSchema, root, `${path}.${key}`, errors, partial);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
166
|
+
function validateAgainstSchema(data: unknown, rootSchema: JsonSchema, partial: boolean): ValidateResult {
|
|
167
|
+
return validateInstance(data, rootSchema, rootSchema, partial, true);
|
|
168
|
+
}
|
|
218
169
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (typeof key === "string" && !(key in value)) {
|
|
224
|
-
errors.push(`${path}: missing required property '${key}'`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
170
|
+
/** Validate `data` against a JSON Schema root. Returns human-readable error messages. */
|
|
171
|
+
export function validateConfigDocument(data: unknown, rootSchema: JsonSchema): ValidateResult {
|
|
172
|
+
return validateAgainstSchema(data, rootSchema, false);
|
|
173
|
+
}
|
|
229
174
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (!(key in propMap)) {
|
|
234
|
-
errors.push(`${path}: unknown property '${key}'`);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
175
|
+
/** Validate present keys only — skips `required` checks (partial writes / bootstrap). */
|
|
176
|
+
export function validateConfigDocumentPartial(data: unknown, rootSchema: JsonSchema): ValidateResult {
|
|
177
|
+
return validateAgainstSchema(data, rootSchema, true);
|
|
238
178
|
}
|
|
239
179
|
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
path: string,
|
|
245
|
-
errors: string[],
|
|
246
|
-
partial: boolean,
|
|
247
|
-
): void {
|
|
248
|
-
if (!Array.isArray(value)) {
|
|
249
|
-
errors.push(`${path}: must be array`);
|
|
250
|
-
return;
|
|
180
|
+
function resolveSchema(schema: JsonSchema, root: JsonSchema): JsonSchema | undefined {
|
|
181
|
+
const ref = schema.$ref;
|
|
182
|
+
if (typeof ref !== "string" || !ref.startsWith("#/")) {
|
|
183
|
+
return schema;
|
|
251
184
|
}
|
|
252
|
-
|
|
253
|
-
|
|
185
|
+
const target = resolveJsonPointer(root, ref);
|
|
186
|
+
if (typeof target === "object" && target !== null && !Array.isArray(target)) {
|
|
187
|
+
return target as JsonSchema;
|
|
254
188
|
}
|
|
255
|
-
|
|
256
|
-
|
|
189
|
+
return schema;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeTypes(type: unknown): string[] {
|
|
193
|
+
if (typeof type === "string") {
|
|
194
|
+
return [type];
|
|
257
195
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
for (let i = 0; i < value.length; i++) {
|
|
261
|
-
validateValue(value[i], items as JsonSchema, root, `${path}[${i}]`, errors, partial);
|
|
262
|
-
}
|
|
196
|
+
if (Array.isArray(type)) {
|
|
197
|
+
return type.filter((t): t is string => typeof t === "string");
|
|
263
198
|
}
|
|
199
|
+
return [];
|
|
264
200
|
}
|
|
265
201
|
|
|
266
202
|
export function validateParsedConfigValue(
|
|
@@ -271,10 +207,9 @@ export function validateParsedConfigValue(
|
|
|
271
207
|
if (!propertySchema) {
|
|
272
208
|
return parsed;
|
|
273
209
|
}
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
throw new Error(errors[0] ?? "Invalid config value");
|
|
210
|
+
const result = validateInstance(parsed, propertySchema, rootSchema, false, false);
|
|
211
|
+
if (!result.valid) {
|
|
212
|
+
throw new Error(result.errors[0] ?? "Invalid config value");
|
|
278
213
|
}
|
|
279
214
|
return parsed;
|
|
280
215
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -219,6 +219,10 @@ export interface CliHttpWireContext {
|
|
|
219
219
|
clientIp: string;
|
|
220
220
|
path: string;
|
|
221
221
|
method: string;
|
|
222
|
+
/** W3C trace id when `traceparent` is present on the request. */
|
|
223
|
+
traceId?: string;
|
|
224
|
+
/** Span id for this server hop when `traceparent` is present. */
|
|
225
|
+
spanId?: string;
|
|
222
226
|
}
|
|
223
227
|
|
|
224
228
|
/** Wire-level MCP hooks on JSON-RPC messages (observe-only). */
|
|
@@ -596,7 +600,7 @@ export interface InvokeHookContext {
|
|
|
596
600
|
locals: CliLocals;
|
|
597
601
|
runtime?: ServerRuntime;
|
|
598
602
|
appConfig: AnyAppConfigSnapshot;
|
|
599
|
-
http?: { request: Request; clientIp: string; requestId: string };
|
|
603
|
+
http?: { request: Request; clientIp: string; requestId: string; traceId?: string; spanId?: string };
|
|
600
604
|
mcp?: { rpcMethod: string; toolName?: string; requestId: string };
|
|
601
605
|
}
|
|
602
606
|
|
|
@@ -641,9 +645,9 @@ export interface ReadinessContext {
|
|
|
641
645
|
runtime: ServerRuntime;
|
|
642
646
|
}
|
|
643
647
|
|
|
644
|
-
/** Framework logging defaults (ECS json or human text on stderr). */
|
|
648
|
+
/** Framework logging defaults (ECS Logging json or human text on stderr). */
|
|
645
649
|
export interface CliLogConfig {
|
|
646
|
-
/** `json` = ECS lines; `text` = human stderr lines. Default: `json`. */
|
|
650
|
+
/** `json` = ECS Logging lines; `text` = human stderr lines. Default: `json`. */
|
|
647
651
|
format?: "json" | "text";
|
|
648
652
|
/** Tee stderr + append; relative paths resolve under the app config dir. */
|
|
649
653
|
file?: string;
|
|
@@ -651,6 +655,16 @@ export interface CliLogConfig {
|
|
|
651
655
|
access?: boolean;
|
|
652
656
|
/** Emit error events after the hook pipeline. Default: true. */
|
|
653
657
|
errors?: boolean;
|
|
658
|
+
/**
|
|
659
|
+
* Add non-standard fields to each JSON log line after the ECS baseline.
|
|
660
|
+
* Cannot override `@timestamp`, `log.level`, `message`, `ecs.version`, or service fields.
|
|
661
|
+
*/
|
|
662
|
+
enrich?: (ctx: import("../log/ecs.ts").LogEnrichContext) => Record<string, unknown>;
|
|
663
|
+
/**
|
|
664
|
+
* Full control over JSON log line serialization. When set, bypasses the built-in ECS formatter.
|
|
665
|
+
* The consumer owns the entire line (including newline omission).
|
|
666
|
+
*/
|
|
667
|
+
serialize?: (ctx: import("../log/ecs.ts").LogEnrichContext) => string;
|
|
654
668
|
}
|
|
655
669
|
|
|
656
670
|
/**
|
package/src/docs/http-guide.ts
CHANGED
|
@@ -80,6 +80,16 @@ export function generateHttpGuide(root: CliProgram): string {
|
|
|
80
80
|
"",
|
|
81
81
|
'Errors use `{ "error": "..." }` with `400`, `404`, `503`, or `500`.',
|
|
82
82
|
"",
|
|
83
|
+
"## Logging",
|
|
84
|
+
"",
|
|
85
|
+
"Server logs go to **stderr** (one JSON object per line by default).",
|
|
86
|
+
"",
|
|
87
|
+
"- Configure with `program.log` on the program root",
|
|
88
|
+
"- **`enrich`** — add custom JSON fields on top of the default line",
|
|
89
|
+
"- **`serialize`** — replace the formatter and emit your own line shape",
|
|
90
|
+
"",
|
|
91
|
+
"See the argsbarg [logging guide](https://github.com/bdombro/bun-argsbarg/blob/main/docs/logging.md) for examples and the full `LogEnrichContext` shape.",
|
|
92
|
+
"",
|
|
83
93
|
];
|
|
84
94
|
|
|
85
95
|
if (root.appConfig?.entries && Object.keys(root.appConfig.entries).length > 0) {
|
|
@@ -139,7 +139,7 @@ export async function executeHttpRouteCall(
|
|
|
139
139
|
pathParams: Record<string, string>,
|
|
140
140
|
query: Record<string, string>,
|
|
141
141
|
body: Record<string, unknown>,
|
|
142
|
-
http?: { request: Request; clientIp: string; requestId: string },
|
|
142
|
+
http?: { request: Request; clientIp: string; requestId: string; traceId?: string; spanId?: string },
|
|
143
143
|
): Promise<HeadlessToolCallResult> {
|
|
144
144
|
const argvResult = httpRequestToArgv(cli.program, route, pathParams, query, body);
|
|
145
145
|
if ("error" in argvResult) {
|
package/src/hooks/run.ts
CHANGED
|
@@ -134,8 +134,13 @@ export async function runErrorPipeline(
|
|
|
134
134
|
failureKind === "unexpected" && obscureUnexpected ? obscureUnexpectedClientMessage() : clientError.message;
|
|
135
135
|
|
|
136
136
|
emitter?.emitInvokeError(failureKind, err, displayMessage, {
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
labels: {
|
|
138
|
+
invocation: hookCtx.invocation,
|
|
139
|
+
path: hookCtx.path.join(" "),
|
|
140
|
+
},
|
|
141
|
+
requestId: hookCtx.locals.requestId,
|
|
142
|
+
traceId: hookCtx.http?.traceId,
|
|
143
|
+
spanId: hookCtx.http?.spanId,
|
|
139
144
|
});
|
|
140
145
|
|
|
141
146
|
return { failureKind, clientError, errorMsg: displayMessage };
|
package/src/http/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
headlessFailureToHttpResponse,
|
|
10
10
|
headlessSuccessToHttpResponse,
|
|
11
11
|
} from "../headless/tool-call.ts";
|
|
12
|
+
import { extractTraceContext, formatTraceparent } from "../log/trace.ts";
|
|
12
13
|
import type { Cli } from "../runtime/cli.ts";
|
|
13
14
|
import { leafHttpResponseDefaults } from "../runtime/exposure.ts";
|
|
14
15
|
import type { ResolvedHttpServeConfig } from "../server/overrides.ts";
|
|
@@ -70,12 +71,14 @@ export async function handleApiRequest(
|
|
|
70
71
|
const requestId = randomUUID();
|
|
71
72
|
const url = new URL(request.url);
|
|
72
73
|
const clientIp = resolveClientIp(request, trustProxy);
|
|
74
|
+
const trace = extractTraceContext(request);
|
|
73
75
|
const wireCtx: CliHttpWireContext = {
|
|
74
76
|
request,
|
|
75
77
|
requestId,
|
|
76
78
|
clientIp,
|
|
77
79
|
path: url.pathname,
|
|
78
80
|
method: request.method,
|
|
81
|
+
...(trace ? { traceId: trace.traceId, spanId: trace.spanId } : {}),
|
|
79
82
|
};
|
|
80
83
|
const hooks = cli.server?.httpHooks ?? cli.program.httpServer?.hooks;
|
|
81
84
|
const emitter = cli.server?.emitter;
|
|
@@ -99,8 +102,22 @@ export async function handleApiRequest(
|
|
|
99
102
|
durationMs,
|
|
100
103
|
requestId,
|
|
101
104
|
clientIp,
|
|
105
|
+
traceId: trace?.traceId,
|
|
106
|
+
spanId: trace?.spanId,
|
|
107
|
+
});
|
|
108
|
+
if (!trace) {
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
const headers = new Headers(response.headers);
|
|
112
|
+
headers.set(
|
|
113
|
+
"traceparent",
|
|
114
|
+
formatTraceparent({ traceId: trace.traceId, spanId: trace.spanId, sampled: trace.sampled }),
|
|
115
|
+
);
|
|
116
|
+
return new Response(response.body, {
|
|
117
|
+
status: response.status,
|
|
118
|
+
statusText: response.statusText,
|
|
119
|
+
headers,
|
|
102
120
|
});
|
|
103
|
-
return response;
|
|
104
121
|
};
|
|
105
122
|
|
|
106
123
|
await hooks?.onRequest?.(wireCtx);
|
|
@@ -168,6 +185,7 @@ export async function handleApiRequest(
|
|
|
168
185
|
request,
|
|
169
186
|
clientIp,
|
|
170
187
|
requestId,
|
|
188
|
+
...(trace ? { traceId: trace.traceId, spanId: trace.spanId } : {}),
|
|
171
189
|
});
|
|
172
190
|
if (result.ok) {
|
|
173
191
|
const leafHttp = leafHttpResponseDefaults(match.route.leaf);
|
package/src/index.ts
CHANGED
|
@@ -79,6 +79,8 @@ export {
|
|
|
79
79
|
wantsExplicitJson,
|
|
80
80
|
} from "./headless/routing.ts";
|
|
81
81
|
export { generateOpenApi, openApiJson } from "./http/openapi.ts";
|
|
82
|
+
export type { EcsLogEvent, LogEnrichContext } from "./log/ecs.ts";
|
|
83
|
+
export { ECS_VERSION, formatEcsLine } from "./log/ecs.ts";
|
|
82
84
|
export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
|
|
83
85
|
export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
|
|
84
86
|
export { Cli, type CliInvokeKind, type CliInvokeResult } from "./runtime/cli.ts";
|
package/src/log/ecs.test.ts
CHANGED
|
@@ -3,10 +3,10 @@ Unit tests for ECS log line formatting.
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import { formatEcsLine } from "./ecs.ts";
|
|
6
|
+
import { ECS_VERSION, formatEcsLine, mergeEnrichFields, PROTECTED_ECS_KEYS } from "./ecs.ts";
|
|
7
7
|
|
|
8
8
|
describe("formatEcsLine", () => {
|
|
9
|
-
test("includes
|
|
9
|
+
test("includes ECS Logging baseline fields", () => {
|
|
10
10
|
const line = formatEcsLine(
|
|
11
11
|
{ name: "myapp", version: "7.0.0" },
|
|
12
12
|
{
|
|
@@ -21,9 +21,49 @@ describe("formatEcsLine", () => {
|
|
|
21
21
|
expect(parsed["event.action"]).toBe("http.server.start");
|
|
22
22
|
expect(parsed.message).toBe("HTTP API listening");
|
|
23
23
|
expect(parsed["log.level"]).toBe("info");
|
|
24
|
+
expect(parsed["ecs.version"]).toBe(ECS_VERSION);
|
|
24
25
|
expect(typeof parsed["@timestamp"]).toBe("string");
|
|
25
26
|
});
|
|
26
27
|
|
|
28
|
+
test("nests labels object instead of flattening", () => {
|
|
29
|
+
const line = formatEcsLine(
|
|
30
|
+
{ name: "app", version: "1.0.0" },
|
|
31
|
+
{
|
|
32
|
+
level: "info",
|
|
33
|
+
message: "ok",
|
|
34
|
+
labels: { request_id: "abc", team: "demo" },
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
38
|
+
expect(parsed.labels).toEqual({ request_id: "abc", team: "demo" });
|
|
39
|
+
expect(parsed["labels.request_id"]).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("includes trace and canonical http fields", () => {
|
|
43
|
+
const line = formatEcsLine(
|
|
44
|
+
{ name: "app", version: "1.0.0" },
|
|
45
|
+
{
|
|
46
|
+
level: "info",
|
|
47
|
+
message: "GET /workspaces",
|
|
48
|
+
action: "http.access",
|
|
49
|
+
traceId: "0af7651916cd43dd8448eb211c80319c",
|
|
50
|
+
spanId: "b7ad6b7169203331",
|
|
51
|
+
fields: {
|
|
52
|
+
"http.request.method": "GET",
|
|
53
|
+
"url.path": "/workspaces",
|
|
54
|
+
"http.response.status_code": 200,
|
|
55
|
+
"event.duration": 45_000_000,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
60
|
+
expect(parsed["trace.id"]).toBe("0af7651916cd43dd8448eb211c80319c");
|
|
61
|
+
expect(parsed["span.id"]).toBe("b7ad6b7169203331");
|
|
62
|
+
expect(parsed["http.request.method"]).toBe("GET");
|
|
63
|
+
expect(parsed["url.path"]).toBe("/workspaces");
|
|
64
|
+
expect(parsed["event.duration"]).toBe(45_000_000);
|
|
65
|
+
});
|
|
66
|
+
|
|
27
67
|
test("includes error stack fields", () => {
|
|
28
68
|
const err = new Error("boom");
|
|
29
69
|
const line = formatEcsLine(
|
|
@@ -40,4 +80,30 @@ describe("formatEcsLine", () => {
|
|
|
40
80
|
expect(parsed["error.type"]).toBe("Error");
|
|
41
81
|
expect(String(parsed["error.stack_trace"])).toContain("boom");
|
|
42
82
|
});
|
|
83
|
+
|
|
84
|
+
test("enrich merges additive fields but not protected keys", () => {
|
|
85
|
+
const line = formatEcsLine({
|
|
86
|
+
service: { name: "app", version: "1.0.0" },
|
|
87
|
+
event: { level: "info", message: "hello" },
|
|
88
|
+
enrich: () => ({
|
|
89
|
+
"custom.field": "yes",
|
|
90
|
+
message: "overridden",
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
94
|
+
expect(parsed.message).toBe("hello");
|
|
95
|
+
expect(parsed["custom.field"]).toBe("yes");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("mergeEnrichFields", () => {
|
|
100
|
+
test("skips protected and existing keys", () => {
|
|
101
|
+
const line: Record<string, unknown> = { message: "keep", "trace.id": "set" };
|
|
102
|
+
mergeEnrichFields(line, { message: "nope", "ecs.version": "0.0.0", "trace.id": "bad", extra: 1 });
|
|
103
|
+
expect(line.message).toBe("keep");
|
|
104
|
+
expect(line["ecs.version"]).toBeUndefined();
|
|
105
|
+
expect(line["trace.id"]).toBe("set");
|
|
106
|
+
expect(line.extra).toBe(1);
|
|
107
|
+
expect(PROTECTED_ECS_KEYS.has("message")).toBe(true);
|
|
108
|
+
});
|
|
43
109
|
});
|