@iskra-bun/core 0.1.0 → 0.2.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.
@@ -1,29 +1,84 @@
1
1
  import { z } from 'zod';
2
2
 
3
- export const AppConfigSchema = z.object({
4
- name: z.string().default('IskraApp'),
5
- debug: z.boolean().default(false),
6
- logger: z.object({
7
- level: z.string().default('info')
8
- }).default({}),
9
- otel: z.object({
10
- enabled: z.boolean().default(true),
11
- endpoint: z.string().default('http://localhost:4318'),
12
- serviceName: z.string().optional(),
13
- serviceVersion: z.string().default('0.1.0'),
14
- environment: z.string().optional(),
15
- metricIntervalMs: z.number().default(60_000),
16
- resourceAttributes: z.record(z.string()).optional(),
17
- instrumentations: z.record(z.object({ enabled: z.boolean().optional() })).optional(),
18
- }).optional(),
19
- processes: z.record(z.object({
20
- command: z.string(),
21
- args: z.array(z.string()).optional(),
22
- mode: z.enum(['daemon', 'oneshot', 'stdio']).default('daemon'),
23
- restartOnCrash: z.boolean().default(false),
24
- env: z.record(z.string()).optional()
25
- })).optional()
3
+ const RestartBackoffSchema = z.object({
4
+ initialMs: z.number().positive().optional(),
5
+ maxMs: z.number().positive().optional(),
6
+ factor: z.number().positive().optional(),
26
7
  });
27
8
 
9
+ // Sections owned by the kits are validated here for shape only, and every
10
+ // object is `.passthrough()`: AppConfig promises `[key: string]: any`, and a
11
+ // plain z.object() silently strips unknown keys — which used to drop `db`,
12
+ // `kv` and `socket` from app.config.ts entirely.
13
+ export const AppConfigSchema = z
14
+ .object({
15
+ name: z.string().default('IskraApp'),
16
+ debug: z.boolean().default(false),
17
+ logger: z
18
+ .object({
19
+ level: z.string().default('info'),
20
+ })
21
+ .passthrough()
22
+ .default({}),
23
+ otel: z
24
+ .object({
25
+ enabled: z.boolean().default(true),
26
+ endpoint: z.string().default('http://localhost:4318'),
27
+ serviceName: z.string().optional(),
28
+ serviceVersion: z.string().default('0.1.0'),
29
+ environment: z.string().optional(),
30
+ metricIntervalMs: z.number().default(60_000),
31
+ resourceAttributes: z.record(z.string()).optional(),
32
+ // passthrough: the instrumentations' own options (hooks, redactedQueryParams) were stripped.
33
+ instrumentations: z.record(z.object({ enabled: z.boolean().optional() }).passthrough()).optional(),
34
+ })
35
+ .passthrough()
36
+ .optional(),
37
+ shutdownSignals: z.union([z.array(z.string()), z.literal(false)]).optional(),
38
+ shutdownTimeoutMs: z.number().positive().optional(),
39
+ processes: z
40
+ .record(
41
+ z
42
+ .object({
43
+ command: z.string(),
44
+ args: z.array(z.string()).optional(),
45
+ mode: z.enum(['daemon', 'oneshot', 'stdio']).default('daemon'),
46
+ restartOnCrash: z.boolean().default(false),
47
+ maxRestarts: z.number().int().nonnegative().optional(),
48
+ restartCooldown: z.number().nonnegative().optional(),
49
+ restartBackoff: RestartBackoffSchema.optional(),
50
+ env: z.record(z.string()).optional(),
51
+ inheritEnv: z.union([z.boolean(), z.array(z.string())]).optional(),
52
+ maxPendingStdinBytes: z.number().int().positive().optional(),
53
+ })
54
+ .passthrough(),
55
+ )
56
+ .optional(),
57
+ socket: z
58
+ .object({
59
+ enabled: z.boolean(),
60
+ port: z.number().optional(),
61
+ adapter: z.enum(['bun', 'socket.io']).optional(),
62
+ })
63
+ .passthrough()
64
+ .optional(),
65
+ kv: z
66
+ .object({
67
+ driver: z.enum(['memory', 'redis']),
68
+ connection: z.any().optional(),
69
+ })
70
+ .passthrough()
71
+ .optional(),
72
+ db: z
73
+ .object({
74
+ driver: z.enum(['postgres', 'mysql', 'sqlite', 'libsql']),
75
+ url: z.string(),
76
+ authToken: z.string().optional(),
77
+ })
78
+ .passthrough()
79
+ .optional(),
80
+ })
81
+ .passthrough();
82
+
28
83
  export type AppConfigInput = z.input<typeof AppConfigSchema>;
29
84
  export type AppConfigOutput = z.output<typeof AppConfigSchema>;
package/src/env.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The deployment environment, read when called: `NODE_ENV` through a computed
3
+ * key, because `bun build` replaces a literal `process.env.NODE_ENV` with its
4
+ * value at build time ("development" when unset), so a compiled binary ignored
5
+ * the NODE_ENV it ran with.
6
+ */
7
+ const NODE_ENV = 'NODE_ENV';
8
+
9
+ /** `NODE_ENV` as the process runs with it, or undefined when unset or empty. */
10
+ export function nodeEnv(): string | undefined {
11
+ return process.env[NODE_ENV] || undefined;
12
+ }
13
+
14
+ /**
15
+ * Whether development conveniences apply (pretty logs, http auth URLs, sample
16
+ * secrets, `disableCSRFCheck`): only with `NODE_ENV` set to `development` or
17
+ * `test`.
18
+ */
19
+ export function isDevelopmentEnv(env: string | undefined = nodeEnv()): boolean {
20
+ return env === 'development' || env === 'test';
21
+ }
22
+
23
+ /**
24
+ * Whether production safeguards apply: in every environment but `development`
25
+ * and `test`, including an unset NODE_ENV and names like `staging`. They used
26
+ * to apply only to NODE_ENV=production exactly, so a deploy that forgot it
27
+ * sent non-Secure cookies and accepted placeholder secrets.
28
+ */
29
+ export function isProductionEnv(env: string | undefined = nodeEnv()): boolean {
30
+ return !isDevelopmentEnv(env);
31
+ }
package/src/errors.ts CHANGED
@@ -97,7 +97,11 @@ export class ConfigError extends IskraError {
97
97
 
98
98
  export class DriverError extends IskraError {
99
99
  constructor(message: string, options?: { code?: ErrorCode; cause?: Error; context?: Record<string, unknown> }) {
100
- super(message, { code: options?.code ?? ErrorCodes.DRIVER_INIT_FAILED, cause: options?.cause, context: options?.context });
100
+ super(message, {
101
+ code: options?.code ?? ErrorCodes.DRIVER_INIT_FAILED,
102
+ cause: options?.cause,
103
+ context: options?.context,
104
+ });
101
105
  this.name = 'DriverError';
102
106
  }
103
107
  }
@@ -116,7 +120,10 @@ export class PluginError extends IskraError {
116
120
  export class LifecycleError extends IskraError {
117
121
  public readonly failures: PromiseRejectedResult[];
118
122
 
119
- constructor(message: string, options: { failures?: PromiseRejectedResult[]; cause?: Error; context?: Record<string, unknown> }) {
123
+ constructor(
124
+ message: string,
125
+ options: { failures?: PromiseRejectedResult[]; cause?: Error; context?: Record<string, unknown> },
126
+ ) {
120
127
  super(message, { code: ErrorCodes.LIFECYCLE_STOP_FAILED, cause: options.cause, context: options.context });
121
128
  this.name = 'LifecycleError';
122
129
  this.failures = options.failures ?? [];
package/src/index.ts CHANGED
@@ -3,5 +3,6 @@ export * from './types';
3
3
  export * from './logger';
4
4
  export * from './errors';
5
5
  export * from './otel';
6
+ export * from './env';
6
7
  export * from './config/loader';
7
8
  export * from './config/schema';
@@ -1,16 +1,200 @@
1
1
  import pino from 'pino';
2
+ import pretty from 'pino-pretty';
3
+ import { isDevelopmentEnv } from '../env';
4
+
5
+ /**
6
+ * Field names whose values are replaced with `[REDACTED]`, at any depth. Keys
7
+ * are compared lowercased and without `-` or `_`, so `apiKey`, `api_key` and
8
+ * `X-API-Key` are the same key.
9
+ */
10
+ const REDACTED_KEYS = [
11
+ 'password',
12
+ 'pass',
13
+ 'passwd',
14
+ 'apiKey',
15
+ 'apiSecret',
16
+ 'token',
17
+ 'authToken',
18
+ 'accessToken',
19
+ 'refreshToken',
20
+ 'idToken',
21
+ 'secret',
22
+ 'clientSecret',
23
+ 'secretKey',
24
+ 'privateKey',
25
+ 'authorization',
26
+ 'proxyAuthorization',
27
+ 'cookie',
28
+ 'setCookie',
29
+ 'sessionId',
30
+ ] as const;
31
+
32
+ /**
33
+ * Keys ending in one of these are redacted too (normalized the same way):
34
+ * `dbPassword`, `x-api-key`, `x-auth-token`, `AWS_SECRET_ACCESS_KEY`,
35
+ * `webhookSecret`.
36
+ */
37
+ const REDACTED_SUFFIXES = ['password', 'passwd', 'secret', 'token', 'apikey', 'secretkey', 'privatekey', 'accesskey'];
38
+
39
+ const normalizeKey = (key: string): string => key.toLowerCase().replace(/[-_]/g, '');
40
+ const SENSITIVE = new Set<string>(REDACTED_KEYS.map(normalizeKey));
41
+ const isSensitive = (key: string): boolean => {
42
+ const normalized = normalizeKey(key);
43
+ return SENSITIVE.has(normalized) || REDACTED_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
44
+ };
45
+ const CENSOR = '[REDACTED]';
46
+ /** Objects deeper than this are logged as they are. */
47
+ const MAX_DEPTH = 8;
48
+ const IN_PROGRESS = Symbol('in progress');
49
+
50
+ /** The prototype of what pino's err serializer returns (an error's fields, causes included). */
51
+ const SERIALIZED_ERROR_PROTO = Object.getPrototypeOf(pino.stdSerializers.err(new Error()));
52
+
53
+ const isPlainObject = (value: object): boolean => {
54
+ const proto = Object.getPrototypeOf(value);
55
+ return proto === Object.prototype || proto === null || proto === SERIALIZED_ERROR_PROTO;
56
+ };
57
+
58
+ /** Query parameter names whose values are masked in messages. */
59
+ const SECRET_PARAM = /pass|secret|token|key|sig|auth/i;
60
+
61
+ /**
62
+ * Passwords in `scheme://user:password@host` and secret-looking query
63
+ * parameters (`?authToken=`, `&X-Amz-Signature=`) of a message: drivers put
64
+ * the connection string in the errors they throw. Bounded quantifiers keep
65
+ * both patterns linear on long input.
66
+ */
67
+ function maskCredentials(text: string): string {
68
+ return text
69
+ .replace(/\b([a-z][\w+.-]{0,30}:\/\/[^\s:@/]{0,256}):[^\s@/]{1,256}@/gi, '$1:[REDACTED]@')
70
+ .replace(/([?&])([\w.-]{1,100})=([^&\s'"#]+)/g, (match, sep: string, name: string) =>
71
+ SECRET_PARAM.test(name) ? `${sep}${name}=[REDACTED]` : match,
72
+ );
73
+ }
74
+
75
+ /**
76
+ * An Error as pino's err serializer writes it (type, message and stack with
77
+ * their causes, and its own fields), scrubbed. pino serializes errors after
78
+ * the log formatter runs, so an HTTP client's error (`config.headers
79
+ * .Authorization`) or a Redis error (`command.args` of AUTH) went out as is.
80
+ */
81
+ function scrubError(err: Error, depth: number, done: WeakMap<object, unknown>): unknown {
82
+ const serialized = { ...pino.stdSerializers.err(err) } as Record<string, unknown>;
83
+ if (typeof serialized.message === 'string') serialized.message = maskCredentials(serialized.message);
84
+ if (typeof serialized.stack === 'string') serialized.stack = maskCredentials(serialized.stack);
85
+ // Deep: an error keeps the client's objects (axios's `request`, whose
86
+ // `_options.headers` hold the Authorization header), which are class
87
+ // instances that JSON.stringify writes out in full.
88
+ return scrubEntries(serialized, depth, done, true);
89
+ }
90
+
91
+ /**
92
+ * Replaces the value of every sensitive key, at any depth, in a copy of the
93
+ * objects that contain one (the caller's objects are not modified). pino's
94
+ * own `redact` has no recursive wildcard, and listing each key at every depth
95
+ * made logging 14 times slower. Errors are serialized and scrubbed here (see
96
+ * scrubError), and objects with a toJSON() are scrubbed as what it returns;
97
+ * other class instances are left to pino, except inside an error.
98
+ */
99
+ function scrub(value: unknown, depth: number, done: WeakMap<object, unknown>, deep = false): unknown {
100
+ if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return value;
101
+ if (done.has(value)) {
102
+ const result = done.get(value);
103
+ // Still being walked: a cycle, which pino would print as [Circular].
104
+ return result === IN_PROGRESS ? '[Circular]' : result;
105
+ }
106
+ const isError = value instanceof Error;
107
+ const toJSON = (value as { toJSON?: unknown }).toJSON;
108
+ const walk = isError || Array.isArray(value) || isPlainObject(value) || typeof toJSON === 'function' || deep;
109
+ if (!walk) return value;
110
+ done.set(value, IN_PROGRESS);
111
+ let result: unknown;
112
+ if (isError) {
113
+ result = scrubError(value, depth, done);
114
+ } else if (Array.isArray(value)) {
115
+ let copy: unknown[] | undefined;
116
+ value.forEach((item, i) => {
117
+ const scrubbed = scrub(item, depth + 1, done, deep);
118
+ if (scrubbed !== item) (copy ??= value.slice())[i] = scrubbed;
119
+ });
120
+ result = copy ?? value;
121
+ } else if (isPlainObject(value)) {
122
+ result = scrubEntries(value as Record<string, unknown>, depth, done, deep);
123
+ } else if (typeof toJSON === 'function') {
124
+ // Written as its toJSON(), which is what JSON.stringify writes: an axios
125
+ // error's `config.headers` is an AxiosHeaders instance whose
126
+ // Authorization header went out as is.
127
+ result = scrub(toJSON.call(value), depth, done, deep);
128
+ } else {
129
+ // Inside an error: another class instance, as JSON.stringify writes it
130
+ // (its own enumerable fields).
131
+ result = scrubEntries({ ...(value as Record<string, unknown>) }, depth, done, deep);
132
+ }
133
+ done.set(value, result);
134
+ return result;
135
+ }
136
+
137
+ /** An object with its sensitive keys censored and its values scrubbed; a copy if anything changed. */
138
+ function scrubEntries(
139
+ value: Record<string, unknown>,
140
+ depth: number,
141
+ done: WeakMap<object, unknown>,
142
+ deep = false,
143
+ ): Record<string, unknown> {
144
+ let copy: Record<string, unknown> | undefined;
145
+ for (const [key, item] of Object.entries(value)) {
146
+ const scrubbed = isSensitive(key) ? CENSOR : scrub(item, depth + 1, done, deep);
147
+ if (scrubbed !== item) (copy ??= { ...value })[key] = scrubbed;
148
+ }
149
+ // A nested serialized error (pino's prototype) becomes a plain object either way.
150
+ return copy ?? (Object.getPrototypeOf(value) === SERIALIZED_ERROR_PROTO ? { ...value } : value);
151
+ }
2
152
 
3
153
  export const createLogger = (name: string, level: string = 'info') => {
4
- return pino({
154
+ // Pretty output only in development/test: JSON otherwise, NODE_ENV unset included.
155
+ const isDev = isDevelopmentEnv();
156
+ const options: pino.LoggerOptions = {
5
157
  name,
6
158
  level,
7
- transport: {
8
- target: 'pino-pretty',
9
- options: {
10
- colorize: true
11
- }
12
- }
13
- });
159
+ formatters: {
160
+ log: (object) => scrub(object, 0, new WeakMap()) as Record<string, unknown>,
161
+ },
162
+ // The formatter above already serialized errors (scrubbed): pino's own
163
+ // err serializer would take that plain object for an error again and
164
+ // rewrite its `type` as "Object".
165
+ serializers: {
166
+ err: (value: unknown) => (value instanceof Error ? scrub(value, 0, new WeakMap()) : value),
167
+ },
168
+ hooks: {
169
+ // Messages are strings the formatter never sees: mask connection
170
+ // string passwords there too, including the message pino takes
171
+ // from an error logged on its own (`logger.error(err)`).
172
+ logMethod(args, method) {
173
+ const masked: unknown[] = args.map((arg) => (typeof arg === 'string' ? maskCredentials(arg) : arg));
174
+ if (masked[0] instanceof Error && typeof masked[1] !== 'string') {
175
+ masked.splice(1, 0, maskCredentials(masked[0].message));
176
+ }
177
+ return method.apply(this, masked as Parameters<typeof method>);
178
+ },
179
+ },
180
+ redact: {
181
+ paths: ['config.env', '*.data'],
182
+ censor: CENSOR,
183
+ },
184
+ };
185
+ // pino-pretty as an in-process stream, not a `transport`: a transport runs
186
+ // in a worker thread that loads the module by name at runtime, which fails
187
+ // in a `bun build --compile` binary and crashed it at startup.
188
+ const logger = isDev ? pino(options, pretty({ colorize: true })) : pino(options);
189
+
190
+ // Bindings (`logger.child({ ... })`) do not go through formatters.log:
191
+ // scrub them here. A child's own children inherit this child().
192
+ type Child = (this: pino.Logger, bindings: pino.Bindings, options?: object) => pino.Logger;
193
+ const child = logger.child as unknown as Child;
194
+ logger.child = function (this: pino.Logger, bindings: pino.Bindings, childOptions?: object) {
195
+ return child.call(this, scrub(bindings, 0, new WeakMap()) as pino.Bindings, childOptions);
196
+ } as unknown as typeof logger.child;
197
+ return logger;
14
198
  };
15
199
 
16
200
  export type Logger = pino.Logger;
package/src/otel.ts CHANGED
@@ -12,8 +12,9 @@
12
12
  * actionable error instead of an unhandled module-resolution failure.
13
13
  */
14
14
  import type { OtelConfig } from './types';
15
+ import { nodeEnv } from './env';
15
16
 
16
- let sdkInstance: any = null;
17
+ let sdkInstance: { shutdown(): Promise<void> } | null = null;
17
18
 
18
19
  /** Optional OTel package specifiers, indirected so tsc does not resolve them statically. */
19
20
  const OTEL_MODULES = {
@@ -26,9 +27,202 @@ const OTEL_MODULES = {
26
27
  semconv: '@opentelemetry/semantic-conventions',
27
28
  } as const;
28
29
 
30
+ // The optional OTel packages are loaded by specifier, untyped; these are the
31
+ // parts of them used here.
32
+ type Ctor<T = unknown> = new (options: Record<string, unknown>) => T;
33
+ interface OtelSdkNode {
34
+ NodeSDK: Ctor<{ start(): void; shutdown(): Promise<void> }>;
35
+ }
36
+ interface OtelAutoInstrumentations {
37
+ getNodeAutoInstrumentations(overrides: Record<string, unknown>): unknown;
38
+ }
39
+ interface OtelTraceExporter {
40
+ OTLPTraceExporter: Ctor;
41
+ }
42
+ interface OtelMetricExporter {
43
+ OTLPMetricExporter: Ctor;
44
+ }
45
+ interface OtelSdkMetrics {
46
+ PeriodicExportingMetricReader: Ctor;
47
+ }
48
+ interface OtelSemconv {
49
+ ATTR_SERVICE_NAME?: string;
50
+ ATTR_SERVICE_VERSION?: string;
51
+ }
52
+ /** `@opentelemetry/resources`: 2.x exports resourceFromAttributes(), 1.x the Resource class. */
53
+ export interface OtelResourcesModule {
54
+ resourceFromAttributes?: (attributes: Record<string, string>) => unknown;
55
+ Resource?: new (attributes: Record<string, string>) => unknown;
56
+ }
57
+
29
58
  /** Import an optional module by specifier; the indirection keeps tsc from resolving it. */
30
- function importOptional(specifier: string): Promise<any> {
31
- return import(specifier);
59
+ function importOptional<T>(specifier: string): Promise<T> {
60
+ return import(specifier) as Promise<T>;
61
+ }
62
+
63
+ const DEFAULT_ENDPOINT = 'http://localhost:4318';
64
+ const HTTP_INSTRUMENTATION = '@opentelemetry/instrumentation-http';
65
+
66
+ /**
67
+ * Query parameters whose values are exported as REDACTED in span URLs (see
68
+ * redactUrl). Names are compared without case, `-` or `_`.
69
+ */
70
+ export const SECRET_QUERY_PARAMS: readonly string[] = [
71
+ 'token',
72
+ 'access_token',
73
+ 'refresh_token',
74
+ 'id_token',
75
+ 'auth_token',
76
+ 'session_token',
77
+ 'api_key',
78
+ 'key',
79
+ 'secret',
80
+ 'client_secret',
81
+ 'password',
82
+ 'passwd',
83
+ 'code',
84
+ 'state',
85
+ 'ticket',
86
+ 'otp',
87
+ 'jwt',
88
+ 'signature',
89
+ 'sig',
90
+ 'X-Amz-Signature',
91
+ 'X-Amz-Credential',
92
+ 'X-Amz-Security-Token',
93
+ 'AWSAccessKeyId',
94
+ 'X-Goog-Signature',
95
+ 'X-Goog-Credential',
96
+ ];
97
+
98
+ const normalizeParam = (name: string): string => name.toLowerCase().replace(/[-_]/g, '');
99
+
100
+ function decodeParam(name: string): string {
101
+ try {
102
+ return decodeURIComponent(name.replace(/\+/g, ' '));
103
+ } catch {
104
+ return name;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * `url` (absolute, or a path) with the value of each query parameter named in
110
+ * `params` replaced by REDACTED, the rest kept as it was. Telemetry exported
111
+ * URLs as they were requested: `?api_key=`, `?access_token=`, a presigned
112
+ * URL's signature.
113
+ */
114
+ export function redactUrl(url: string, params: readonly string[] = SECRET_QUERY_PARAMS): string {
115
+ const start = url.indexOf('?');
116
+ if (start === -1 || params.length === 0) return url;
117
+ const names = new Set(params.map(normalizeParam));
118
+ const hash = url.indexOf('#', start);
119
+ const end = hash === -1 ? url.length : hash;
120
+ let redacted = false;
121
+ const query = url
122
+ .slice(start + 1, end)
123
+ .split('&')
124
+ .map((pair) => {
125
+ const eq = pair.indexOf('=');
126
+ if (eq === -1 || !names.has(normalizeParam(decodeParam(pair.slice(0, eq))))) return pair;
127
+ redacted = true;
128
+ return `${pair.slice(0, eq)}=REDACTED`;
129
+ })
130
+ .join('&');
131
+ return redacted ? url.slice(0, start + 1) + query + url.slice(end) : url;
132
+ }
133
+
134
+ /** The URL attributes of an HTTP span, in the stable and the older semantic conventions. */
135
+ const URL_ATTRIBUTES = ['url.full', 'url.query', 'http.url', 'http.target'];
136
+
137
+ /** Rewrites the URL attributes of a recording SDK span (it has `attributes`). */
138
+ function redactSpanUrls(span: unknown, params: readonly string[]): void {
139
+ const { attributes, setAttribute } = (span ?? {}) as {
140
+ attributes?: Record<string, unknown>;
141
+ setAttribute?: (key: string, value: string) => unknown;
142
+ };
143
+ if (!attributes || typeof setAttribute !== 'function') return;
144
+ for (const key of URL_ATTRIBUTES) {
145
+ const value = attributes[key];
146
+ if (typeof value !== 'string') continue;
147
+ // url.query is the query string alone, without its "?".
148
+ const redacted = key === 'url.query' ? redactUrl(`?${value}`, params).slice(1) : redactUrl(value, params);
149
+ if (redacted !== value) setAttribute.call(span, key, redacted);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * The options initOtel() passes to getNodeAutoInstrumentations(): the app's
155
+ * `instrumentations` over Iskra's defaults. The HTTP instrumentation redacts
156
+ * SECRET_QUERY_PARAMS unless `redactedQueryParams` / `redactedQueryParamsServer`
157
+ * are set (instrumentation-http 0.204 / 0.222 and later apply them itself;
158
+ * for older releases a requestHook rewrites the span's URL attributes, before
159
+ * the app's own requestHook runs).
160
+ */
161
+ export function autoInstrumentationOptions(config: OtelConfig): Record<string, unknown> {
162
+ const http: Record<string, unknown> = { ...config.instrumentations?.[HTTP_INSTRUMENTATION] };
163
+ const list = (value: unknown) => (Array.isArray(value) ? (value as string[]) : undefined);
164
+ const client = list(http.redactedQueryParams) ?? SECRET_QUERY_PARAMS;
165
+ const server = list(http.redactedQueryParamsServer) ?? SECRET_QUERY_PARAMS;
166
+ const appHook = http.requestHook;
167
+ return {
168
+ '@opentelemetry/instrumentation-fs': { enabled: false },
169
+ ...config.instrumentations,
170
+ [HTTP_INSTRUMENTATION]: {
171
+ ...http,
172
+ redactedQueryParams: client,
173
+ redactedQueryParamsServer: server,
174
+ requestHook: (span: unknown, request: unknown) => {
175
+ // A ClientRequest (outgoing) has setHeader(); an IncomingMessage does not.
176
+ const outgoing = typeof (request as { setHeader?: unknown } | null)?.setHeader === 'function';
177
+ redactSpanUrls(span, outgoing ? client : server);
178
+ if (typeof appHook === 'function') appHook(span, request);
179
+ },
180
+ },
181
+ };
182
+ }
183
+
184
+ /** Hosts spans may reach in clear text: this machine, a private network, or a name that only resolves inside one. */
185
+ function isPrivateHost(hostname: string): boolean {
186
+ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
187
+ if (host.includes(':')) return host === '::1' || /^f[cd]/.test(host);
188
+ return (
189
+ !host.includes('.') ||
190
+ /\.(localhost|local|internal)$/.test(host) ||
191
+ /^(127|10)\./.test(host) ||
192
+ /^192\.168\./.test(host) ||
193
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host)
194
+ );
195
+ }
196
+
197
+ /**
198
+ * The OTLP endpoint as it is safe to log, its origin (the path, query or
199
+ * userinfo can carry an API key), and whether spans travel to it in clear
200
+ * text beyond this machine and its private network.
201
+ */
202
+ export function describeOtelEndpoint(config: OtelConfig): { origin: string; plaintext: boolean } {
203
+ try {
204
+ const url = new URL(config.endpoint || DEFAULT_ENDPOINT);
205
+ return { origin: url.origin, plaintext: url.protocol === 'http:' && !isPrivateHost(url.hostname) };
206
+ } catch {
207
+ return { origin: '(invalid URL)', plaintext: false };
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Builds a Resource with whichever API the installed `@opentelemetry/resources`
213
+ * provides: 2.x only exports `resourceFromAttributes()` (`Resource` is a type
214
+ * there, so `new Resource()` throws), 1.x exports the `Resource` class.
215
+ */
216
+ export function createResource(resources: OtelResourcesModule, attributes: Record<string, string>): unknown {
217
+ if (typeof resources?.resourceFromAttributes === 'function') {
218
+ return resources.resourceFromAttributes(attributes);
219
+ }
220
+ if (typeof resources?.Resource === 'function') {
221
+ return new resources.Resource(attributes);
222
+ }
223
+ throw new Error(
224
+ '[iskra/otel] Unsupported @opentelemetry/resources: it exports neither resourceFromAttributes() nor Resource',
225
+ );
32
226
  }
33
227
 
34
228
  /**
@@ -45,32 +239,29 @@ export async function initOtel(config: OtelConfig, appName: string): Promise<voi
45
239
  { OTLPTraceExporter },
46
240
  { OTLPMetricExporter },
47
241
  { PeriodicExportingMetricReader },
48
- { Resource },
242
+ resources,
49
243
  semconv,
50
244
  ] = await Promise.all([
51
- importOptional(OTEL_MODULES.sdkNode),
52
- importOptional(OTEL_MODULES.autoInstrumentations),
53
- importOptional(OTEL_MODULES.traceExporter),
54
- importOptional(OTEL_MODULES.metricExporter),
55
- importOptional(OTEL_MODULES.sdkMetrics),
56
- importOptional(OTEL_MODULES.resources),
57
- importOptional(OTEL_MODULES.semconv),
245
+ importOptional<OtelSdkNode>(OTEL_MODULES.sdkNode),
246
+ importOptional<OtelAutoInstrumentations>(OTEL_MODULES.autoInstrumentations),
247
+ importOptional<OtelTraceExporter>(OTEL_MODULES.traceExporter),
248
+ importOptional<OtelMetricExporter>(OTEL_MODULES.metricExporter),
249
+ importOptional<OtelSdkMetrics>(OTEL_MODULES.sdkMetrics),
250
+ importOptional<OtelResourcesModule>(OTEL_MODULES.resources),
251
+ importOptional<OtelSemconv>(OTEL_MODULES.semconv),
58
252
  ]);
59
253
 
60
254
  const serviceName = config.serviceName || appName;
61
- const endpoint = config.endpoint || 'http://localhost:4318';
255
+ const endpoint = config.endpoint || DEFAULT_ENDPOINT;
62
256
 
63
- const resource = new Resource({
64
- [semconv.ATTR_SERVICE_NAME]: serviceName,
65
- [semconv.ATTR_SERVICE_VERSION]: config.serviceVersion || '0.1.0',
66
- 'deployment.environment': config.environment || process.env.NODE_ENV || 'development',
257
+ const resource = createResource(resources, {
258
+ [semconv.ATTR_SERVICE_NAME ?? 'service.name']: serviceName,
259
+ [semconv.ATTR_SERVICE_VERSION ?? 'service.version']: config.serviceVersion || '0.1.0',
260
+ 'deployment.environment': config.environment || nodeEnv() || 'development',
67
261
  ...config.resourceAttributes,
68
262
  });
69
263
 
70
- const instrumentationOverrides: Record<string, any> = {
71
- '@opentelemetry/instrumentation-fs': { enabled: false },
72
- ...config.instrumentations,
73
- };
264
+ const instrumentationOverrides = autoInstrumentationOptions(config);
74
265
 
75
266
  const sdk = new NodeSDK({
76
267
  resource,
@@ -84,14 +275,19 @@ export async function initOtel(config: OtelConfig, appName: string): Promise<voi
84
275
 
85
276
  sdk.start();
86
277
  sdkInstance = sdk;
87
- } catch (err: any) {
278
+ } catch (err) {
88
279
  // Provide a clear error when OTel packages are not installed
89
- if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.code === 'MODULE_NOT_FOUND' || err?.message?.includes('Cannot find')) {
280
+ const e = err as { code?: string; message?: string } | null;
281
+ if (
282
+ e?.code === 'ERR_MODULE_NOT_FOUND' ||
283
+ e?.code === 'MODULE_NOT_FOUND' ||
284
+ e?.message?.includes('Cannot find')
285
+ ) {
90
286
  throw new Error(
91
287
  `[iskra/otel] OpenTelemetry packages are not installed. ` +
92
- `Install them with: bun add @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node ` +
93
- `@opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http ` +
94
- `@opentelemetry/sdk-metrics @opentelemetry/resources @opentelemetry/semantic-conventions`,
288
+ `Install them with: bun add @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node ` +
289
+ `@opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http ` +
290
+ `@opentelemetry/sdk-metrics @opentelemetry/resources @opentelemetry/semantic-conventions`,
95
291
  );
96
292
  }
97
293
  throw err;