@iskra-bun/core 0.1.1 → 0.3.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/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;
package/src/traced.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { SpanStatusCode, trace, type Attributes, type Span, type Tracer } from '@opentelemetry/api';
2
+
3
+ export interface TracedOptions {
4
+ /** Attributes of every span. */
5
+ attributes?: Attributes;
6
+ /** The tracer; default the global provider's `iskra` tracer (no spans without an OpenTelemetry SDK). */
7
+ tracer?: Tracer;
8
+ }
9
+
10
+ function fail(span: Span, error: unknown): void {
11
+ span.recordException(error instanceof Error ? error : new Error(String(error)));
12
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.name : 'Error' });
13
+ }
14
+
15
+ /**
16
+ * Wraps `fn` in a span named `name`, by convention `layer.domain.method`
17
+ * (`repo.usuarios.buscar`): the span is the active one while `fn` runs, so
18
+ * the spans started inside (a query, a call to another service) are its
19
+ * children; a throw or a rejection marks it as an error. Without an
20
+ * OpenTelemetry SDK it costs a no-op span.
21
+ *
22
+ * ```ts
23
+ * const buscar = traced('repo.usuarios.buscar', async (id: number) => oracle.one(SQL, { id }));
24
+ * ```
25
+ */
26
+ export function traced<A extends unknown[], R>(
27
+ name: string,
28
+ fn: (...args: A) => R,
29
+ options: TracedOptions = {},
30
+ ): (...args: A) => R {
31
+ return function (this: unknown, ...args: A): R {
32
+ const tracer = options.tracer ?? trace.getTracer('iskra');
33
+ return tracer.startActiveSpan(name, { attributes: options.attributes }, (span) => {
34
+ let result: R;
35
+ try {
36
+ result = fn.apply(this, args);
37
+ } catch (error) {
38
+ fail(span, error);
39
+ span.end();
40
+ throw error;
41
+ }
42
+ if (result instanceof Promise) {
43
+ return result.then(
44
+ (value) => {
45
+ span.end();
46
+ return value;
47
+ },
48
+ (error: unknown) => {
49
+ fail(span, error);
50
+ span.end();
51
+ throw error;
52
+ },
53
+ ) as R;
54
+ }
55
+ span.end();
56
+ return result;
57
+ });
58
+ };
59
+ }
package/src/types.ts CHANGED
@@ -13,17 +13,42 @@ export interface Plugin {
13
13
  install(app: App): Promise<void> | void;
14
14
  }
15
15
 
16
- export interface Context<T = any> {
16
+ /** What an `app.on()` handler receives; `payload` is the emitted value. */
17
+ export interface Context<T = unknown> {
17
18
  app: App;
18
19
  logger: Logger;
19
20
  payload: T;
20
- reply(data: any): void;
21
+ /** Emits `<event>:reply` with `data`. */
22
+ reply(data: unknown): void;
21
23
  }
22
24
 
25
+ /**
26
+ * What each kit shares through `app.context`, by key, so `app.context.get('db')`
27
+ * is typed. Kits add their keys with declaration merging, and so can an app:
28
+ *
29
+ * ```ts
30
+ * declare module '@iskra-bun/core' {
31
+ * interface AppContextRegistry {
32
+ * bridge: DesktopBridge;
33
+ * }
34
+ * }
35
+ * ```
36
+ */
37
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- filled by declaration merging
38
+ export interface AppContextRegistry {}
39
+
40
+ /**
41
+ * The payload of each app event, by name, so `app.on('process:exit', ...)`
42
+ * and `app.emit(...)` are typed. Kits add their events with declaration
43
+ * merging, and so can an app (as with AppContextRegistry).
44
+ */
45
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- filled by declaration merging
46
+ export interface AppEvents {}
47
+
23
48
  export interface OtelConfig {
24
49
  /** Defaults to true when otel config is present */
25
50
  enabled?: boolean;
26
- /** OTLP endpoint URL. Defaults to http://localhost:4318 */
51
+ /** OTLP endpoint URL. Defaults to http://localhost:4318; use https:// for a collector on another host. */
27
52
  endpoint?: string;
28
53
  /** Overrides app name for the service.name resource attribute */
29
54
  serviceName?: string;
@@ -35,8 +60,14 @@ export interface OtelConfig {
35
60
  metricIntervalMs?: number;
36
61
  /** Additional resource attributes */
37
62
  resourceAttributes?: Record<string, string>;
38
- /** Auto-instrumentation overrides (passed to getNodeAutoInstrumentations) */
39
- instrumentations?: Record<string, { enabled?: boolean }>;
63
+ /**
64
+ * Options of each auto-instrumentation, by package name, passed to
65
+ * getNodeAutoInstrumentations(): `{ enabled: false }`, or its own options
66
+ * (`'@opentelemetry/instrumentation-http': { ignoreIncomingRequestHook,
67
+ * redactedQueryParams }`). The HTTP one redacts SECRET_QUERY_PARAMS in
68
+ * exported URLs unless `redactedQueryParams` says otherwise.
69
+ */
70
+ instrumentations?: Record<string, { enabled?: boolean; [option: string]: unknown }>;
40
71
  }
41
72
 
42
73
  export interface AppConfig {
@@ -46,6 +77,13 @@ export interface AppConfig {
46
77
  level?: string;
47
78
  };
48
79
  otel?: OtelConfig;
80
+ /**
81
+ * Signals that trigger a graceful `stop()` and exit. Default
82
+ * `['SIGTERM', 'SIGINT']` (none under NODE_ENV=test); `false` disables.
83
+ */
84
+ shutdownSignals?: string[] | false;
85
+ /** Max time for a signal-triggered stop before forcing exit(1). Default 10000. */
86
+ shutdownTimeoutMs?: number;
49
87
  processes?: Record<string, ProcessConfig>;
50
88
  socket?: {
51
89
  enabled: boolean;
@@ -53,15 +91,17 @@ export interface AppConfig {
53
91
  adapter?: 'bun' | 'socket.io';
54
92
  };
55
93
  kv?: {
56
- driver: 'memory' | 'redis' | 'libsql';
57
- connection?: any;
94
+ driver: 'memory' | 'redis';
95
+ /** Redis: a URL string, ioredis options, or ioredis options with `url`. */
96
+ connection?: string | Record<string, unknown>;
58
97
  };
59
98
  db?: {
60
99
  driver: 'postgres' | 'mysql' | 'sqlite' | 'libsql';
61
100
  url: string;
62
101
  authToken?: string;
63
102
  };
64
- [key: string]: any;
103
+ /** Sections of other kits or of the app; read them with their own type. */
104
+ [key: string]: unknown;
65
105
  }
66
106
 
67
107
  export interface RestartBackoffConfig {
@@ -80,7 +120,20 @@ export interface ProcessConfig {
80
120
  restartOnCrash?: boolean;
81
121
  maxRestarts?: number;
82
122
  restartCooldown?: number;
123
+ /** Variables set for the child, over the ones it inherits (see `inheritEnv`). */
83
124
  env?: Record<string, string>;
125
+ /**
126
+ * Which of the app's environment variables the child inherits. Default
127
+ * `false`: a minimal set without secrets (PATH, HOME, locale, TZ, temp
128
+ * dir, NODE_ENV…). A list adds those names to it; `true` passes them all
129
+ * (DATABASE_URL, AUTH_SECRET, cloud keys…).
130
+ */
131
+ inheritEnv?: boolean | string[];
132
+ /**
133
+ * `stdio` mode: bytes `send()` lets wait for a child that is not reading
134
+ * its stdin; past this it refuses messages (returns false). Default 8 MiB.
135
+ */
136
+ maxPendingStdinBytes?: number;
84
137
  /** Exponential backoff settings for restarts. Defaults to 1000 ms flat (no backoff). */
85
138
  restartBackoff?: RestartBackoffConfig;
86
139
  }