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:
// 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
recordExceptioncalls are needed in adapters or workflows. - The
causeis 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:
{
"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:
| Question | Query |
|---|---|
| All unavailability errors | exception.type = "Unavailable" |
| All misconfiguration errors | exception.type = "Misconfigured" |
| All errors in a specific service | Filter by service.name |
Architecture boundaries
| Layer | Responsibility |
|---|---|
| Core | Defines 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 middleware | Catches DomainError, maps to HTTP problem+json or GraphQL error extensions. |