Skip to content

Observability

Our APIs use a combination of thrown DomainError and returned Either values. OpenTelemetry instrumentation captures thrown errors automatically. Returned client errors are not exceptions and do not need to be recorded as failures.

How domain errors are captured

Every DomainError extends BaseError, which calls recordException in its constructor:

ts
// packages/error/src/base.ts
export class BaseError extends Error {
	constructor(...args: ConstructorParameters<typeof Error>) {
		super(...args);
		Error.captureStackTrace(this, this.constructor);

		try {
			recordException(this);
		} catch {
			// Never let observability failures break the application.
		}
	}
}

This means:

  • Throwing new Unavailable({ cause: error }) automatically records the exception on the active span.
  • No manual recordException calls are needed in adapters or workflows.
  • The cause is preserved and surfaced in the trace.

Trace anatomy

A typical failed GraphQL request produces the following trace structure:

┌─────────────────────────────────────────────────────────────────────────┐
│ POST /graphql                                                   200ms   │
│  → auto: HttpInstrumentation                                            │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ GraphQL: mutation UserUpdate                                180ms   │ │
│ │  → auto: GraphQLInstrumentation                                     │ │
│ │                                                                     │ │
│ │ status: ERROR                                                       │ │
│ │ exception.type: Unavailable                                         │ │
│ │ exception.message: Service is temporarily unavailable               │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘

The outer spans are created automatically by HttpInstrumentation and GraphQLInstrumentation. The BaseError constructor enriches the innermost active span with the exception event on instantiation.

GraphQL error extensions

When a DomainError reaches a GraphQL entrypoint, the format-error middleware in @repo/core-server includes the error code in the GraphQL response extensions. This gives API consumers a machine-readable reference to the failure:

json
{
	"errors": [
		{
			"message": "An unexpected error occurred",
			"extensions": {
				"code": "UNAVAILABLE"
			}
		}
	]
}

Internal error details are redacted from the message field. The code extension always reflects the DomainErrorCode.

Querying failures

With exceptions recorded on spans, you can query the tracing system to answer operational questions:

QuestionQuery
All unavailability errorsexception.type = "Unavailable"
All misconfiguration errorsexception.type = "Misconfigured"
All errors in a specific serviceFilter by service.name

Architecture boundaries

LayerResponsibility
CoreDefines client error types. No OTel dependency.
Adapter (shell/infra)Throws DomainError on infrastructure failure. Exception captured automatically.
Handler (shell)Command or query that orchestrates the flow. Does not catch or wrap infrastructure errors.
Entrypoint (shell)Maps client errors to protocol responses. Domain errors handled by central middleware.
Error middlewareCatches DomainError, maps to HTTP problem+json or GraphQL error extensions.