Repository navigation
Expand file tree
/
Copy pathlogger.mts
More file actions
148 lines (138 loc) · 6.32 KB
/
Copy pathlogger.mts
File metadata and controls
148 lines (138 loc) · 6.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/*
* Copyright 2026 1o1 Co. Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The proxy's logger: the `Logger` interface every module logs through, and
* the process-wide singleton (the default export) that writes NDJSON to
* stdout at the level `LOG_LEVEL` names (default `info`).
*
* Anything logged under the `error` or the `err` key goes through
* `serializeLoggedError`, an allowlist: an Error keeps its class name, message,
* stack and the fields in `LOGGED_ERROR_FIELDS`, follows `cause` at most
* `MAX_CAUSE_DEPTH` levels, and has URL credentials redacted; every other
* property is dropped. The validation path logs under `error`, `shutdown.mts`
* under `err`; any other key is serialised by pino as it is, so an Error
* belongs under one of these two. Only an `Error` instance is recognised: an
* error-like value from another realm, or a plain object with a `message`,
* passes through as it is.
*/
import pino, { type DestinationStream } from "pino";
/**
* The logging surface the proxy uses.
*
* Kept as a structural interface rather than pino's own type so a caller can
* pass a double, and so swapping the backend does not ripple into every
* module that only ever calls `.warn`.
*/
export interface Logger {
info(...args: [string] | [Record<string, unknown>, string]): void;
warn(...args: [string] | [Record<string, unknown>, string]): void;
error(...args: [string] | [Record<string, unknown>, string]): void;
debug(...args: [string] | [Record<string, unknown>, string]): void;
}
/**
* The fields of an Error that reach a log line. An allowlist, not
* pino's `errWithCause`: that copies every enumerable property, and some
* errors carry bytes nobody chose to log — undici's `HTTPParserError.data` is
* the unparsed rest of the provider's response, which may echo the token the
* proxy just sent. What is kept is what diagnoses a failed call: the class, the
* message, the stack, the socket's `code` / `errno` / `syscall`, and the
* provider's `status` with the bundled clients' classification.
*/
const LOGGED_ERROR_FIELDS = ["code", "errno", "syscall", "status", "refusedCredential"] as const;
/** Deep enough for wrapper → undici → socket, with room; bounded against a cycle. */
const MAX_CAUSE_DEPTH = 5;
/**
* `scheme://user:pass@host` → `scheme://***@host`: everything after `://` up to
* the last `@` before the next `/` or the end of the line.
*
* An error message quotes a URL as it was given, not normalised (`fetch`'s
* refusal of a URL with credentials and its failure to parse one both do), so
* userinfo may carry a space, an `@`, a `?` or a `#` unencoded, and only `/`
* and the end of a line are taken as the end of the authority.
*
* Known limits: a `/` inside a raw password cannot be told from the start of a
* path and ends the match, so the userinfo is not redacted — none of it unless
* an `@` comes before that `/`; and a URL without `//` after its scheme
* (`scheme:/user:pass@host`, `scheme:user:pass@host`, or with backslashes),
* which `fetch` still reads as carrying credentials, is not matched at all. The
* schema refuses a configured introspection URL with userinfo, so no
* credential-bearing URL of the proxy's own reaches `fetch`; the redaction is
* for any other raw URL that reaches a message.
*
* It errs towards redacting: after a bare origin with no path, an `@` later on
* the same line — in a query, a fragment or plain text — takes the text before
* it along. That costs diagnostic text, never a credential, and it never
* reaches past a line of a stack.
*/
const redactUrlCredentials = (text: string): string =>
text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/\n]*@/gi, "$1***@");
/**
* The serialiser for the `error` and `err` keys. A non-Error passes through as
* it is — injection logs a string under `error`.
*/
export const serializeLoggedError = (value: unknown, depth = 0): unknown => {
if (!(value instanceof Error)) return value;
const out: Record<string, unknown> = {
type: value.name,
message: redactUrlCredentials(value.message),
};
if (typeof value.stack === "string") out.stack = redactUrlCredentials(value.stack);
const fields = value as unknown as Record<string, unknown>;
for (const key of LOGGED_ERROR_FIELDS) {
const field = fields[key];
if (typeof field === "string" || typeof field === "number") {
out[key] = typeof field === "string" ? redactUrlCredentials(field) : field;
}
}
if (value.cause !== undefined && depth < MAX_CAUSE_DEPTH) {
out.cause =
value.cause instanceof Error ? serializeLoggedError(value.cause, depth + 1) : "[non-Error cause]";
}
return out;
};
export interface ProxyLoggerOptions {
/** Defaults to `LOG_LEVEL`, then `info`. */
level?: string;
/** Test seam; production writes to stdout. */
destination?: DestinationStream;
}
/**
* Newline-delimited JSON on stdout — the shape a log aggregator ingests
* without a parser.
*
* The level is read here rather than at module load so a test can set
* `LOG_LEVEL` without re-importing the module.
*/
export function createProxyLogger(options?: ProxyLoggerOptions): pino.Logger {
const level = options?.level ?? process.env.LOG_LEVEL ?? "info";
const config = {
name: "proxy",
level,
// pino serialises an Error only under `err`, with a serialiser that
// copies every enumerable property; under `error`, where the validation
// path logs its failures, an Error would be `{}` plus whatever fields it
// declared — no message, no stack, no cause. Both keys get the
// allowlist, so the guarantee does not depend on which name a log line
// chose: see `LOGGED_ERROR_FIELDS`.
serializers: {
error: (value: unknown) => serializeLoggedError(value),
err: (value: unknown) => serializeLoggedError(value),
},
};
return options?.destination ? pino(config, options.destination) : pino(config);
}
const logger: Logger = createProxyLogger();
export default logger;