@ddd-ts-kit/sentry

中文文档
0.106.0发布于 2026/11/01MITTypeScriptES Module构建来源已验证

Error reporting to Sentry or GlitchTip behind the kernel ErrorReporter port: unexpected failures only, correlated with traces, scrubbed before sending.

原始使用文档
文档目录8 节
  1. Composition root
  2. What is reported
  3. With OpenTelemetry
  4. Privacy
  5. Grouping and tags
  6. Options
  7. GlitchTip for development
  8. In a test

English · Français

@ddd-ts-kit/sentry

The kernel ErrorReporter on Sentry or on GlitchTip, which speaks the same protocol: one adapter, only the DSN differs. Unexpected failures only, each one leading to its trace, scrubbed before it leaves the process. Nothing in domain/ or application/ imports this package, or reports anything: handlers throw, and what stands around them reports.

npm i @ddd-ts-kit/sentry @sentry/node @opentelemetry/api

Written against @sentry/node 11: the options it relies on (enableOpenTelemetrySetup, dataCollection) do not exist in earlier majors.

Where the Sentry reporter sits: handlers and aggregates throw and report nothing; five capture points around them, the command and query buses, the HTTP pipeline, the job scheduler, DeadLetterReports on the hooks of the event providers and reportProcessFailures on the process, hand an unexpected failure to the kernel ErrorReporter, once; the composition root binds the SentryErrorReporter of initSentry to it; the event is scrubbed, then passed to the beforeSend of the project, then sent to Sentry or GlitchTip; its trace id leads to the trace in the tracing backend

The code of a use case never sees the reporter. It throws; what stands around it decides whether the failure is an incident, describes it with the identifiers of the work in progress and hands it to the kernel ErrorReporter, the one port this package implements.

Composition root

// platform/observability/errors.ts: imported second, right after platform/observability/tracing.ts
import { reportProcessFailures } from '@ddd-ts-kit/kernel';
import { initSentry } from '@ddd-ts-kit/sentry';
import { loadConfig } from '@/platform/config/load-config';

// { dsn?, environment, release?, scrubMessages } of the validated configuration.
// Without a DSN nothing starts and nothing is reported.
export const errors = initSentry(loadConfig(process.env).errorReporting);

reportProcessFailures(errors); // uncaughtException and unhandledRejection: reported, then exit 1
// main.ts
import { tracing } from '@/platform/observability/tracing'; // first
import { errors } from '@/platform/observability/errors'; // second
import { DeadLetterReports, ExecutionScope } from '@ddd-ts-kit/kernel';
import { kernelModule } from '@ddd-ts-kit/di';
import { createApplication } from '@ddd-ts-kit/di/tsyringe';
import { reportHttpErrors } from '@ddd-ts-kit/http';
import { RabbitMqEventBus } from '@ddd-ts-kit/rabbitmq';

const deadLetters = new DeadLetterReports(errors);
const bus = new RabbitMqEventBus({ url, onFailure: (failure) => deadLetters.onFailure(failure) });

const app = createApplication({
  modules: [
    kernelModule({
      errorReporter: errors, // buses and jobs report through it, app.stop() flushes it
      eventBus: bus,
      eventSubscribers: { forContext: (name) => deadLetters.observing(bus.forConsumer(name)) },
    }),
    ...contexts,
  ],
});

const http = { onError: reportHttpErrors(errors), executionScope: app.resolve(ExecutionScope) };

process.on('SIGTERM', () => void app.stop().then(() => tracing.shutdown()));

Three lines carry it: initSentry, errorReporter: errors, onError: reportHttpErrors(errors). The process handlers and the dead letters are one line each, for who wants them.

init --errors sentry writes exactly these pieces, each in its file: platform/observability/errors.ts as above, with SENTRY_DSN, SENTRY_ENVIRONMENT (default: NODE_ENV), SENTRY_RELEASE and SENTRY_SCRUB_MESSAGES read by platform/config/load-config.ts; errors handed by main.ts to composeApplication, which gives it to kernelModule({ errorReporter }); reportHttpErrors in platform/auth/authentication/http-boundary.ts; DeadLetterReports in platform/events/create-event-provider.ts (the broker) and in platform/events/start-outbox-relay.ts (a publication declared dead).

Load order. platform/observability/tracing.ts stays the first import: OpenTelemetry patches http and the drivers when they are loaded, so it must run before them. initSentry patches nothing, so its place changes nothing for tracing, whichever of the two starts first (both orders are tested). It goes second so that a failure while the rest of the application loads is already reported by reportProcessFailures.

What is reported

Only what nobody planned. A DomainError is a business refusal, an answer and not an incident.

Capture pointAttached byReportsDoes not report
command and query buseskernelModule (the default buses carry errorReportingMiddleware)a command or a query that fails with anything but a DomainErrora DomainError; anything while an event is consumed
HTTPonError: reportHttpErrors(errors)every request answered 5xx, a DomainError mapped to a 5xx includeda 4xx
scheduled jobskernelModule (the default scheduler observes with reportFailedRuns)a run whose outcome is failed, timeouts includeda DomainError, a skipped or missed occurrence
integration eventsDeadLetterReports on the onFailure hook of the provideran event given up: retries exhausted, poison message, publication of the outbox relay declared deada delivery that fails and is retried
processreportProcessFailures(errors)uncaughtException and unhandledRejection, as fatal

One failure, one report. The first capture point that sees an error object reports it and marks it; the boundaries it crosses afterwards recognise it, also as the cause of an error that wraps it. A command that fails inside a request is therefore reported once, by the bus, under the name of the command, and the route that answers 500 stays silent. The report still says where it came from: origin is the origin of the execution context (http, schedule), and the correlation id and the trace lead to the request.

One failure, one report: a request reaches the route, which sends a command on the bus; the handler throws an unexpected error; errorReportingMiddleware of the bus reports it under the name of the command and marks it, then lets it go on; the route answers 500 and reportHttpErrors recognises the marked error and stays silent; the tracker receives one event, with origin http, the correlation id and the trace id

Events. A consumer is retried: its first failures are expected, and reporting them would bury the real incidents. The bus middleware therefore says nothing while an event is consumed, and DeadLetterReports reports the event once its provider gives it up. The hooks of the providers say codes only, so observing(subscriber) remembers what each handler threw: the report carries the real error and its stack. Without it, or for a poison message, the report is a DeadLetterError naming the code. An event buried by a DomainError is reported as a warning: it is lost to its consumer all the same.

An event whose handler keeps failing: at each attempt observing remembers what the handler threw and the provider calls onFailure; while the provider retries, onFailure reports nothing; at the last attempt the provider gives the event up, onFailure hears dead and reports once, with the error the handler really threw, origin event and the operation named after the event type and the consumer

The same steps, played by hand against the doubles of the kernel, as a test of the wiring would:

import {
  DeadLetterReports,
  InMemoryErrorReporter,
  type IntegrationEvent,
  type IntegrationEventCallback,
  type IntegrationEventSubscriber,
} from '@ddd-ts-kit/kernel';

// A provider reduced to what matters here: it keeps the handler it is given.
const handlers = new Map<string, IntegrationEventCallback>();
const provider: IntegrationEventSubscriber = {
  subscribe: (type, handler) => {
    handlers.set(type, handler);

    return { unsubscribe: () => handlers.delete(type) };
  },
};

const reporter = new InMemoryErrorReporter();
const deadLetters = new DeadLetterReports(reporter);

deadLetters.observing(provider).subscribe('ordering.order.placed', async () => {
  throw new Error('ledger unavailable');
});

const event: IntegrationEvent = {
  id: 'evt-1',
  type: 'ordering.order.placed',
  version: 1,
  occurredAt: '2026-03-01T10:00:00.000Z',
  payload: {},
};
const deliver = handlers.get('ordering.order.placed');

// What the provider says of each failed delivery: codes and identifiers only.
const failure = { kind: 'handler', code: 'Error', eventId: 'evt-1', eventType: 'ordering.order.placed', consumer: 'billing' };

await deliver?.(event).catch(() => undefined); // attempt 1 fails: observing remembers the error
deadLetters.onFailure({ ...failure, attempt: 1 }); // retried: no report

await deliver?.(event).catch(() => undefined); // attempt 2, the last one, fails
deadLetters.onFailure({ ...failure, attempt: 2, dead: true }); // given up: one report

// One report: the Error('ledger unavailable') itself, origin 'event', operation 'ordering.order.placed billing'.
export const reports = reporter.reports;

In a handler. Nothing to write: a handler throws, and the capture point around it reports.

import { DomainRefusal, type ErrorReporter } from '@ddd-ts-kit/kernel';

interface PaymentGateway {
  capture(paymentId: string): Promise<'captured' | 'declined'>;
}

interface CapturePaymentCommand {
  readonly paymentId: string;
}

// payments.codes.ts
export const PaymentsCodes = {
  PaymentDeclined: 'PAYMENT_DECLINED',
} as const;

// domain/errors/payment-declined.error.ts
export class PaymentDeclined extends DomainRefusal {
  readonly code = PaymentsCodes.PaymentDeclined;

  static forPayment(paymentId: string) {
    return PaymentDeclined.refusal(`Payment ${paymentId} was declined.`, { paymentId });
  }
}

// Instead of: a handler that reports by itself, and hides the failure from its caller.
export class CapturePaymentReportingItself {
  constructor(
    private readonly gateway: PaymentGateway,
    private readonly errors: ErrorReporter,
  ) {}

  async execute(command: CapturePaymentCommand): Promise<void> {
    try {
      await this.gateway.capture(command.paymentId);
    } catch (error) {
      this.errors.report(error, { origin: 'command', operation: 'CapturePayment', severity: 'error' });
    }
  }
}

// Write: a handler that throws. A refusal is a DomainError, answered and never reported;
// anything else goes up to the bus, which reports it once, with the actor, the tenant and the trace.
export class CapturePaymentHandler {
  constructor(private readonly gateway: PaymentGateway) {}

  async execute(command: CapturePaymentCommand): Promise<void> {
    if ((await this.gateway.capture(command.paymentId)) === 'declined') {
      throw PaymentDeclined.forPayment(command.paymentId);
    }
  }
}

With OpenTelemetry

@ddd-ts-kit/tracing owns the tracer provider, the context manager, the propagator and the auto-instrumentations. initSentry leaves all of them alone:

  • enableOpenTelemetrySetup: false: the SDK registers no tracer provider and no propagator;
  • none of the SDK's default integrations, and enableRuntimeChannelInjection: false: no second instrumentation of http or of a driver;
  • no tracesSampleRate unless given: no performance data goes to the tracker.

What links the two is one read: when a failure is reported, the reporter takes the trace id and the span id of the active OpenTelemetry span and puts them in contexts.trace of the event. From an issue in Sentry or GlitchTip, the trace id opens the trace in the tracing backend. The spans themselves are exported once, by the exporter of the kit.

Privacy

DefaultOpt in
requestnever collected. If something else adds one: method, URL without query string, headers that carry neither a credential nor an address. No body, no cookiea project beforeSend
userthe actor id, nothing else: no email, no name, no address
tenantnot senttagTenant: true
local variablesnever
breadcrumbsnone is recorded automatically: no console, no outgoing callSentry.addBreadcrumb where the project decides
exception messagesent, the values a failed statement quotes redactedscrubMessages: true removes it for every error that is not a DomainError
values a failed statement quotesnever, in a message, a cause or what an integration copied of the error (extra, contexts): the statement stays, placeholders included

Messages. The message is the first thing read in an error tracker, and the first place where personal data leaks: a driver quotes the row it refused. The default keeps it, because an issue titled [message removed] costs every investigation, and because the tracker is an internal tool whose access and retention the project controls. With scrubMessages: true only the messages the project wrote itself survive, those of its DomainErrors; type, code, stack and trace remain for the others. Turn it on when the tracker is hosted by a third party or when the data is sensitive enough that no operator should read it there. The loggers of the kit (StdoutLogger, @ddd-ts-kit/pino, @ddd-ts-kit/winston) take the option under the same name, with the same rule and the same marker, SCRUBBED_MESSAGE of the kernel.

Values of a failed statement. Whatever scrubMessages says, scrubEvent replaces by [redacted] each value a driver quotes, and keeps the statement, which is the signal: the parameters Drizzle writes after params: in a DrizzleQueryError; the key of a violated unique or foreign key constraint (Key (email)=([redacted]) already exists., in a message or a detail); the row a not-null or a check constraint refused (Failing row contains ([redacted]).); a value PostgreSQL could not read (invalid input syntax for type integer: "[redacted]"); the arguments Prisma renders of a call it refused; a field named parameters, params or bindings that an integration copied of the error. Knex on PostgreSQL quotes $1, and TypeORM the message of the server, whose forms are the ones above.

beforeSend runs last, on the scrubbed event, and may drop it by answering null.

The DSN stays inside the SDK client. A SentryErrorReporter that is logged, spread or serialised shows SentryErrorReporter [client concealed]; the client of the SDK, if you log it yourself, shows its options.

Grouping and tags

The fingerprint never contains the message, so identifiers in a message do not turn one problem into thousands of issues: a DomainError is its class and its code; any other error is its class, its code when it has one, and the function and file of its first frame, without line numbers.

Tags: origin, operation, correlation_id, bounded_context, error_code, and tenant_id on request. The fields a project adds to ErrorReportExtensions are sent as the report context.

Options

OptionDefault
dsnnonewithout it initSentry answers a NoopErrorReporter and starts nothing. SENTRY_DSN is not read behind the configuration
environment, releasenone
enabledtruefalse reports nothing, whatever the DSN
sampleRate1share of the error events sent
tracesSampleRatenoneonly for spans started through the Sentry API; the spans of the kit never go there
scrubMessagesfalsesee "Privacy"
tagTenantfalse
beforeSendnoneruns after the scrubber of the kit
integrationsnoneadded to linked errors and source context lines

flush(timeoutMs) answers true when nothing is left to send. app.stop() calls it with two seconds; reportProcessFailures does before exiting.

GlitchTip for development

GlitchTip 6 runs as one service next to its PostgreSQL (14 or later) and, recommended by its documentation, Valkey. The development image bootstraps a user, an organization and a project and prints the DSN:

services:
  glitchtip:
    image: glitchtip/glitchtip:6.2.6
    profiles: [errors]
    depends_on:
      glitchtip-postgres: { condition: service_healthy }
      glitchtip-valkey: { condition: service_started }
    ports: ['127.0.0.1:8000:8000']
    environment:
      SERVER_ROLE: all_in_one # web and worker in one process
      DATABASE_URL: postgres://postgres:postgres@glitchtip-postgres:5432/glitchtip
      VALKEY_URL: redis://glitchtip-valkey:6379
      SECRET_KEY: local-development-only-not-a-secret # never in production: openssl rand -hex 32
      GLITCHTIP_DOMAIN: http://localhost:8000
      EMAIL_URL: consolemail://
      DEFAULT_FROM_EMAIL: glitchtip@localhost
      DEBUG: 'true' # development only: required by the bootstrap below
      GLITCHTIP_BOOTSTRAP_DEV: 'true' # user [email protected] / admin_pass, organization, project, DSN
    healthcheck:
      test: [CMD, python, healthcheck.py]
      interval: 10s
      timeout: 6s
      retries: 6
      start_period: 180s # the first start migrates the database
  glitchtip-postgres:
    image: postgres:18-alpine
    profiles: [errors]
    environment: { POSTGRES_DB: glitchtip, POSTGRES_PASSWORD: postgres }
    volumes: ['glitchtip-postgres:/var/lib/postgresql']
    healthcheck:
      test: [CMD-SHELL, pg_isready -U postgres -d glitchtip]
      interval: 5s
      retries: 12
  glitchtip-valkey:
    image: valkey/valkey:9-alpine
    profiles: [errors]

volumes:
  glitchtip-postgres:
docker compose --profile errors up -d --wait
docker compose logs glitchtip | grep 'DSN:'

Put the DSN in SENTRY_DSN; from another container of the stack, replace localhost with glitchtip. Sign in at http://localhost:8000 with the user above.

In a test

Nothing of this package: the test application binds InMemoryErrorReporter, and app.errors.reports lists what the buses and the jobs reported, as data: each entry is { error, report }, the error as it was thrown and the ErrorReport that describes it.

版本与发布记录

@ddd-ts-kit/sentry 共发布 63 个版本,首次发布于 2026/10/11;过去 12 个月发布了 63 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.106.0latest2026/11/012 天前
0.105.02026/10/312 天前
0.104.02026/10/313 天前
0.103.02026/10/313 天前
0.102.02026/10/313 天前
0.101.02026/10/303 天前

关于 @ddd-ts-kit/sentry 的常见问题

@ddd-ts-kit/sentry 是什么?

向内核ErrorReporter端口后面的Sentry或GlitchTip报告错误:只有意外的失败,与跟踪相关,在发送之前清除。

如何安装 @ddd-ts-kit/sentry?

在项目目录运行 npm install @ddd-ts-kit/sentry,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@sentry/node(^11.6.0)、@opentelemetry/api(^1.9.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@ddd-ts-kit/sentry 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ddd-ts-kit/sentry 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@ddd-ts-kit/sentry 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.12.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@ddd-ts-kit/sentry 可以商用吗?

@ddd-ts-kit/sentry 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ddd-ts-kit/sentry 还在维护吗?

最新版本 0.106.0 发布于 2026/11/01(2 天前);至今共发布 63 个版本,其中过去 12 个月发布了 63 个;首次发布于 2026/10/11。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。