@ryanzeng/nest-observe 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @ryanzeng/nest-observe
1
+ ![header](https://capsule-render.vercel.app/api?type=waving&color=gradient&height=220&section=header&text=nest-observe&fontSize=48&fontColor=ffffff&animation=fadeIn)
2
2
 
3
3
  面向 NestJS 的零配置、Vendor Neutral Observability SDK。基于 OpenTelemetry,通过标准 OTLP 同时发送 Traces、Metrics 和 Logs,可连接 OpenObserve、Grafana、Jaeger、Datadog、Elastic 等兼容后端。
4
4
 
@@ -44,7 +44,7 @@ OBSERVE_ENVIRONMENT=production
44
44
 
45
45
  ## Nest Module
46
46
 
47
- `register` 已能在加载阶段自动挂载 HTTP、Nest Controller、Provider、Prisma 和 Logger instrumentation。也可以导入全局 Module;它为较晚初始化或测试场景提供 Discovery fallback,并在 Nest 关闭时 flush/shutdown:
47
+ `register` 已能在加载阶段自动挂载 HTTP、Nest Controller、Provider 和 Logger instrumentation。也可以导入全局 Module;它为较晚初始化或测试场景提供 Discovery fallback,并在 Nest 关闭时 flush/shutdown:
48
48
 
49
49
  ```ts
50
50
  import { Module } from '@nestjs/common';
@@ -58,6 +58,75 @@ export class AppModule {}
58
58
 
59
59
  Module-only 模式还会注册一个全局 Nest interceptor,补采因 HTTP 模块已经加载而无法挂载底层 hook 的已匹配 Controller 请求指标。它与早期 HTTP instrumentation 共享请求状态,不会重复计数。
60
60
 
61
+ ## 可选 Instrumentation
62
+
63
+ 核心包不内置 Prisma、Redis 或其他数据库客户端 instrumentation。应用只安装自己实际使用的适配包,并在目标客户端被加载之前注册:
64
+
65
+ ```bash
66
+ # Prisma
67
+ pnpm add @prisma/instrumentation
68
+
69
+ # node-redis
70
+ pnpm add @opentelemetry/instrumentation-redis
71
+
72
+ # ioredis
73
+ pnpm add @opentelemetry/instrumentation-ioredis
74
+
75
+ # Pino / Winston(日志类,见"自动采集内容 → Logs")
76
+ pnpm add @opentelemetry/instrumentation-pino
77
+ pnpm add @opentelemetry/instrumentation-winston
78
+ ```
79
+
80
+ 创建一个只负责注册应用级 instrumentation 的文件,例如 `src/observability.instrumentation.ts`:
81
+
82
+ ```ts
83
+ import { registerInstrumentations } from '@opentelemetry/instrumentation';
84
+ import { PrismaInstrumentation } from '@prisma/instrumentation';
85
+ import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
86
+
87
+ export const unregisterApplicationInstrumentations = registerInstrumentations({
88
+ instrumentations: [
89
+ new PrismaInstrumentation(),
90
+ new IORedisInstrumentation(),
91
+ ],
92
+ });
93
+ ```
94
+
95
+ 如果应用使用的是 `redis` 包而不是 `ioredis`,替换为:
96
+
97
+ ```ts
98
+ import { RedisInstrumentation } from '@opentelemetry/instrumentation-redis';
99
+
100
+ new RedisInstrumentation();
101
+ ```
102
+
103
+ 然后将注册文件放在应用入口的最前面。使用早期 `register` 初始化时:
104
+
105
+ ```ts
106
+ import './observability.instrumentation';
107
+ import '@ryanzeng/nest-observe/register';
108
+ import { NestFactory } from '@nestjs/core';
109
+ import { AppModule } from './app.module';
110
+ ```
111
+
112
+ 仅使用 `ObserveModule.forRoot()` 时同样先加载应用级 instrumentation:
113
+
114
+ ```ts
115
+ import './observability.instrumentation';
116
+ import { NestFactory } from '@nestjs/core';
117
+ import { AppModule } from './app.module';
118
+ ```
119
+
120
+ 应用级 instrumentation 会使用 `nest-observe` 注册的全局 tracer provider,因此不应再创建第二个 `NodeTracerProvider` 或第二套 OTLP exporter。对于会重排 import 的 bundler,生产环境应使用 Node 的 `--require`(CommonJS)或 `--import`(ESM)预加载编译后的 instrumentation 文件,确保它早于 Prisma/Redis 客户端执行。
121
+
122
+ ### 为什么必须放在入口第一条 import
123
+
124
+ OTel instrumentation 通过 hook Node 的模块加载器工作:当目标模块(如 `@prisma/client`)第一次被 require/import 时,hook 替换它的导出(`PrismaClient` 类等);模块一旦进入 `require.cache`,加载流程不会再走一遍,hook 永远不会再触发。事后补救也不可行——ESM 命名空间只读,CJS 下应用已持有的类引用也不受缓存修改影响。
125
+
126
+ 而 import 的求值顺序是"先 imports、后模块体":`import { AppModule }` 会把整个依赖图(包括 `PrismaService` → `@prisma/client`)加载完,之后才执行 main.ts 的函数体。因此任何运行在 Nest DI 生命周期里的注册(包括 `ObserveModule.forRoot()`)都晚于客户端加载——这正是 `forRoot()` 不提供第三方客户端 instrumentation 参数的原因,只有 import 声明顺序的第一位早于它。Nest 语义层采集(Controller/Provider 方法)不受此限制:那些对象由 DI 在运行时逐个创建,天然晚于 instrumentation 就位。
127
+
128
+ OTel 官方对"注册过晚"的处理同样只是警告、无法补救(`_warnOnPreloadedModules`),所以放错位置的注册不会报错,只会静默失效——排查"span 没有出现"问题时,请先检查注册文件是否在入口第一条 import。
129
+
61
130
  ## 装饰器
62
131
 
63
132
  ```ts
@@ -90,17 +159,45 @@ export class OrderService {
90
159
  Traces:
91
160
 
92
161
  - Node HTTP client/server、Nest Controller/handler、Provider method
93
- - Prisma instrumentation
162
+ - 应用可按需组合 Prisma、Redis、ioredis 等标准 OpenTelemetry instrumentation
94
163
  - `@Trace()` 自定义 span 和自然 parent/child context
95
164
  - Controller/Provider 异常事件、错误状态及 stack trace
96
165
 
97
166
  Logs:
98
167
 
99
168
  - 默认 Nest `Logger` 的 `verbose/debug/log/warn/error/fatal`
100
- - 对象和数组消息会在脱敏后保持结构化 body,由观测后端或查询侧决定如何展开、展示或序列化
169
+ - 遵循 Nest `ConsoleLogger` 的重载解析语义,保留 `error(message, stack?, context?)`、附加消息和结构化参数
170
+ - `Error` 或显式 stack 会映射为 OTLP `exception.type`、`exception.message`、`exception.stacktrace` 属性
171
+ - 尊重 Nest 11+ 的实例级日志级别过滤(`logLevels` / `setLogLevels` / `Logger.overrideLogger`),上传内容与控制台输出一致
172
+ - 对象和数组消息会在脱敏后保持结构化 body;附加的结构化参数会展开为 OTLP attributes,同时保持首个 message 为 body
101
173
  - OTLP severity、`nestjs.context`、`trace_id`、`span_id`
102
174
  - `service.name`、`service.version`、`deployment.environment.name`
103
- - `StructuredLogEmitter` 抽象可用于后续 Pino/Winston adapter
175
+
176
+ **采集边界**:自动桥接覆盖所有经过 `ConsoleLogger` 的日志(包括默认 `Logger`)。自定义 `LoggerService` 若完全不经过 `ConsoleLogger`(例如直接封装 Pino/Winston),不会被自动采集。两种接入方式:
177
+
178
+ 一是用 `StructuredLogEmitter` 桥接,在自定义 Logger 里显式 emit:
179
+
180
+ ```ts
181
+ import { Injectable, LoggerService } from '@nestjs/common';
182
+ import { OpenTelemetryLogEmitter, redact } from '@ryanzeng/nest-observe';
183
+
184
+ @Injectable()
185
+ export class CustomLogger implements LoggerService {
186
+ private readonly emitter = new OpenTelemetryLogEmitter();
187
+
188
+ error(message: unknown, context?: string): void {
189
+ // 继续执行你的 Pino/Winston 输出……
190
+ this.emitter.emit({
191
+ body: redact(message),
192
+ severityText: 'ERROR',
193
+ attributes: context ? { 'nestjs.context': context } : {},
194
+ timestamp: Date.now(),
195
+ });
196
+ }
197
+ }
198
+ ```
199
+
200
+ 二是注册 OTel 官方的 `@opentelemetry/instrumentation-pino` 或 `@opentelemetry/instrumentation-winston`(方式同上方"可选 Instrumentation"),日志会使用 SDK 注册的全局 Logger Provider,进入同一条 OTLP 管道。
104
201
 
105
202
  Metrics:
106
203
 
package/dist/index.d.mts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { LogRecordExporter, LoggerProvider } from '@opentelemetry/sdk-logs';
2
2
  import { MetricReader, MeterProvider } from '@opentelemetry/sdk-metrics';
3
3
  import { SpanExporter, SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base';
4
- import { Attributes, Meter, Tracer, Span, Context } from '@opentelemetry/api';
5
- import { Logger } from '@opentelemetry/api-logs';
4
+ import { LogAttributes, Logger } from '@opentelemetry/api-logs';
6
5
  import { InstrumentationConfig, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
7
6
  import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
7
+ import { Meter, Tracer, Span, Context } from '@opentelemetry/api';
8
8
  import { IncomingMessage, ServerResponse } from 'node:http';
9
9
  import { NestInterceptor, ExecutionContext, CallHandler, DynamicModule } from '@nestjs/common';
10
10
  import { Observable } from 'rxjs';
@@ -109,7 +109,7 @@ declare function Trace(nameOrOptions?: string | TraceOptions): TraceDecorator;
109
109
  interface StructuredLogRecord {
110
110
  body: unknown;
111
111
  severityText: 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
112
- attributes: Attributes;
112
+ attributes: LogAttributes;
113
113
  timestamp?: number;
114
114
  }
115
115
  interface StructuredLogEmitter {
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { LogRecordExporter, LoggerProvider } from '@opentelemetry/sdk-logs';
2
2
  import { MetricReader, MeterProvider } from '@opentelemetry/sdk-metrics';
3
3
  import { SpanExporter, SpanProcessor, ReadableSpan } from '@opentelemetry/sdk-trace-base';
4
- import { Attributes, Meter, Tracer, Span, Context } from '@opentelemetry/api';
5
- import { Logger } from '@opentelemetry/api-logs';
4
+ import { LogAttributes, Logger } from '@opentelemetry/api-logs';
6
5
  import { InstrumentationConfig, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
7
6
  import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
7
+ import { Meter, Tracer, Span, Context } from '@opentelemetry/api';
8
8
  import { IncomingMessage, ServerResponse } from 'node:http';
9
9
  import { NestInterceptor, ExecutionContext, CallHandler, DynamicModule } from '@nestjs/common';
10
10
  import { Observable } from 'rxjs';
@@ -109,7 +109,7 @@ declare function Trace(nameOrOptions?: string | TraceOptions): TraceDecorator;
109
109
  interface StructuredLogRecord {
110
110
  body: unknown;
111
111
  severityText: 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
112
- attributes: Attributes;
112
+ attributes: LogAttributes;
113
113
  timestamp?: number;
114
114
  }
115
115
  interface StructuredLogEmitter {
package/dist/index.js CHANGED
@@ -347,6 +347,71 @@ function bodyValue(message) {
347
347
  return redactText(message.stack ?? `${message.name}: ${message.message}`);
348
348
  return redact(message);
349
349
  }
350
+ function isPlainObject(value) {
351
+ if (value === null || typeof value !== "object") return false;
352
+ const prototype = Object.getPrototypeOf(value);
353
+ if (prototype === null) return true;
354
+ const constructor = Object.prototype.hasOwnProperty.call(prototype, "constructor") ? prototype.constructor : void 0;
355
+ return typeof constructor === "function" && constructor instanceof constructor && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(Object);
356
+ }
357
+ function isStackFormat(value) {
358
+ return typeof value === "string" && /^(.)+\n\s+at .+:\d+:\d+/.test(value);
359
+ }
360
+ function fallbackMessages(logger, values) {
361
+ if (values.length <= 1) return { messages: values, context: logger.context };
362
+ const lastValue = values.at(-1);
363
+ const contextName = typeof lastValue === "string" ? lastValue : logger.context;
364
+ const remaining = typeof lastValue === "string" ? values.slice(0, -1) : values;
365
+ if (logger.options?.structuredParams === false) {
366
+ return { messages: remaining, context: contextName };
367
+ }
368
+ const messages = [remaining[0]];
369
+ const structuredParams = [];
370
+ for (const value of remaining.slice(1)) {
371
+ if (isPlainObject(value)) structuredParams.push(value);
372
+ else messages.push(value);
373
+ }
374
+ const params = structuredParams.length > 0 ? Object.assign({}, ...structuredParams) : void 0;
375
+ return { messages, context: contextName, ...params ? { params } : {} };
376
+ }
377
+ function fallbackError(logger, values) {
378
+ if (values.length === 2) {
379
+ if (isStackFormat(values[1])) {
380
+ return { messages: [values[0]], stack: values[1], context: logger.context };
381
+ }
382
+ return fallbackMessages(logger, values);
383
+ }
384
+ const trailingValue = values.at(-1);
385
+ if (isStackFormat(trailingValue)) {
386
+ return { ...fallbackMessages(logger, values.slice(0, -1)), stack: trailingValue };
387
+ }
388
+ const parsed = fallbackMessages(logger, values);
389
+ if (parsed.messages.length <= 1) return parsed;
390
+ const possibleStack = parsed.messages.at(-1);
391
+ if (typeof possibleStack !== "string" && possibleStack !== void 0) return parsed;
392
+ return {
393
+ ...parsed,
394
+ messages: parsed.messages.slice(0, -1),
395
+ ...possibleStack === void 0 ? {} : { stack: possibleStack }
396
+ };
397
+ }
398
+ function parseLogCall(logger, method, message, args) {
399
+ const values = [message, ...args];
400
+ const nestParser = method === "error" ? logger.getContextAndStackAndMessagesToPrint : logger.getContextAndMessagesToPrint;
401
+ if (typeof nestParser === "function") return nestParser.call(logger, values);
402
+ return method === "error" ? fallbackError(logger, values) : fallbackMessages(logger, values);
403
+ }
404
+ function exceptionAttributes(message, stack) {
405
+ const error = message instanceof Error ? message : void 0;
406
+ const stacktrace = stack ?? error?.stack;
407
+ return {
408
+ ...error ? {
409
+ "exception.type": error.name,
410
+ "exception.message": redactText(error.message)
411
+ } : {},
412
+ ...stacktrace ? { "exception.stacktrace": redactText(stacktrace) } : {}
413
+ };
414
+ }
350
415
  var NestLoggerInstrumentation = class {
351
416
  constructor(emitter, resourceAttributes = {}) {
352
417
  this.emitter = emitter;
@@ -368,18 +433,30 @@ var NestLoggerInstrumentation = class {
368
433
  const resourceAttributes = this.resourceAttributes;
369
434
  prototype[method] = function(message, ...args) {
370
435
  try {
436
+ const gated = this;
437
+ if (typeof gated.isLevelEnabled === "function" && !gated.isLevelEnabled(method)) {
438
+ return original.call(this, message, ...args);
439
+ }
371
440
  const spanContext = import_api3.trace.getActiveSpan()?.spanContext();
372
- const contextName = this.context ?? (typeof args.at(-1) === "string" ? String(args.at(-1)) : void 0);
373
- const attributes = { ...resourceAttributes };
374
- if (contextName) attributes["nestjs.context"] = contextName;
375
- if (spanContext?.traceId) attributes.trace_id = spanContext.traceId;
376
- if (spanContext?.spanId) attributes.span_id = spanContext.spanId;
377
- emitter.emit({
378
- body: bodyValue(message),
379
- severityText: SEVERITY[method],
380
- attributes,
381
- timestamp: Date.now()
382
- });
441
+ const parsed = parseLogCall(this, method, message, args);
442
+ const timestamp = Date.now();
443
+ for (const parsedMessage of parsed.messages) {
444
+ const params = parsed.params ? redact(parsed.params) : {};
445
+ const attributes = {
446
+ ...params,
447
+ ...resourceAttributes,
448
+ ...exceptionAttributes(parsedMessage, parsed.stack)
449
+ };
450
+ if (parsed.context) attributes["nestjs.context"] = parsed.context;
451
+ if (spanContext?.traceId) attributes.trace_id = spanContext.traceId;
452
+ if (spanContext?.spanId) attributes.span_id = spanContext.spanId;
453
+ emitter.emit({
454
+ body: bodyValue(parsedMessage),
455
+ severityText: SEVERITY[method],
456
+ attributes,
457
+ timestamp
458
+ });
459
+ }
383
460
  } catch {
384
461
  }
385
462
  return original.call(this, message, ...args);
@@ -697,7 +774,6 @@ var import_exporter_metrics_otlp_proto = require("@opentelemetry/exporter-metric
697
774
  var import_exporter_trace_otlp_proto = require("@opentelemetry/exporter-trace-otlp-proto");
698
775
  var import_instrumentation2 = require("@opentelemetry/instrumentation");
699
776
  var import_instrumentation_http = require("@opentelemetry/instrumentation-http");
700
- var import_instrumentation3 = require("@prisma/instrumentation");
701
777
  var import_sdk_logs = require("@opentelemetry/sdk-logs");
702
778
  var import_sdk_metrics = require("@opentelemetry/sdk-metrics");
703
779
  var import_sdk_trace_base = require("@opentelemetry/sdk-trace-base");
@@ -863,7 +939,7 @@ var import_node_os2 = require("os");
863
939
  // package.json
864
940
  var package_default = {
865
941
  name: "@ryanzeng/nest-observe",
866
- version: "0.1.3",
942
+ version: "0.1.4",
867
943
  description: "Zero-config, vendor-neutral OpenTelemetry observability for NestJS",
868
944
  keywords: [
869
945
  "nestjs",
@@ -948,8 +1024,7 @@ var package_default = {
948
1024
  "@opentelemetry/sdk-logs": "^0.221.0",
949
1025
  "@opentelemetry/sdk-metrics": "^2.10.0",
950
1026
  "@opentelemetry/sdk-trace-base": "^2.10.0",
951
- "@opentelemetry/sdk-trace-node": "^2.10.0",
952
- "@prisma/instrumentation": "^7.10.0"
1027
+ "@opentelemetry/sdk-trace-node": "^2.10.0"
953
1028
  },
954
1029
  devDependencies: {
955
1030
  "@nestjs/common": "^12.0.1",
@@ -1264,7 +1339,6 @@ function observe(options = {}) {
1264
1339
  providerTracing: config.providerTracing,
1265
1340
  controllerTracing: config.controllerTracing
1266
1341
  }));
1267
- if (config.traces) instrumentations.push(new import_instrumentation3.PrismaInstrumentation());
1268
1342
  (0, import_instrumentation2.registerInstrumentations)({
1269
1343
  instrumentations,
1270
1344
  ...tracerProvider ? { tracerProvider } : {},