Skip to content

Observability

Every Mantle Lambda handler gets structured logging, CloudWatch metrics, and X-Ray tracing automatically — no configuration required beyond naming your operation.

What you get for free

All define*Handler factories (defineApiHandler, defineEventBridgeHandler, defineScheduledHandler) call withObservability() internally. For every invocation this provides:

  • Cold start detection — emits a ColdStart metric on the first invocation in an execution environment
  • Correlation ID — extracted from the event (API Gateway request ID, EventBridge detail) or generated as a UUID; injected into every log entry automatically
  • Attempt/Success metrics<OperationName>Attempt on entry, <OperationName>Success on clean completion
  • X-Ray span — wraps the invocation in a span named after operationName
  • Structured log contextoperationName, correlationId, and traceId in every log line
  • Error logging — structured error log with operation context on any unhandled exception
  • Metrics flushpublishStoredMetrics() runs in a finally block; you never need to call it yourself
typescript
import { buildValidatedResponse } from '@j0nathan-ll0yd/core'
import { defineApiHandler, z } from '@j0nathan-ll0yd/validation'

const ResponseSchema = z.object({ synced: z.number() })

const api = defineApiHandler({ auth: 'bearer', operationName: 'SyncData' })
export const handler = api(async ({ context, body }) => {
  // structured logging, metrics, and tracing are already active
  return buildValidatedResponse(context, 200, { synced: 0 }, ResponseSchema)
})

Automatic CloudWatch metrics

MetricUnitWhen
ColdStartCountFirst invocation only
<OperationName>AttemptCountEvery invocation
<OperationName>SuccessCountSuccessful completions

Failed invocations emit Attempt but not Success — error rate is 1 - (Success / Attempt).

The metrics namespace is set from the name field in mantle.config.ts and injected as METRICS_NAMESPACE automatically.

Structured logging

The logger is pre-configured with Lambda context for every invocation:

json
{
  "level": "INFO",
  "message": "Handler invoked",
  "service": "my-app",
  "operationName": "SyncData",
  "correlationId": "abc-123-def",
  "traceId": "1-abc-def",
  "timestamp": "2026-03-26T12:00:00.000Z"
}

Add your own log entries with the logger singleton from @j0nathan-ll0yd/observability:

typescript
import { defineApiHandler } from '@j0nathan-ll0yd/validation'
import { logger } from '@j0nathan-ll0yd/observability'

const api = defineApiHandler({ auth: 'bearer', operationName: 'SyncData' })
export const handler = api(async ({ context, body }) => {
  logger.info('Processing sync request', { itemCount: body.items.length })

  const count = await DataQueries.upsertBatch(body.items)

  logger.info('Sync complete', { synced: count })
  return buildValidatedResponse(context, 200, { synced: count }, ResponseSchema)
})

correlationId, traceId, and operationName are injected automatically — do not add them manually.

Reserved keys

The log transport owns five attribute names: level, message, timestamp, service, and sampling_rate. If a structured attribute you pass collides with one of these, the framework renames it with an app_ prefix (for example timestampapp_timestamp) so your value is preserved and the framework value is never overwritten. This applies to the logInfo/logWarn/logError/logDebug helpers, direct logger.debug/info/warn/error/trace/critical(message, attributes) calls (string message + attributes form), and the Lambda@Edge logger. It does not rewrite reserved names passed via the single-object form (logger.info({ message, timestamp })), via appendKeys/appendPersistentKeys/setPersistentLogAttributes, or on a logger.createChild() instance — do not send reserved names through those paths.

typescript
logInfo('AUDIT', { handler: 'sync', timestamp: eventTime })
// emitted record: { ..., "handler": "sync", "app_timestamp": <eventTime>, "timestamp": <logger's own> }

In the rare case where both a reserved key and its app_-prefixed target are supplied (e.g. timestamp and app_timestamp together), the extra value is preserved under the next free slot (app_timestamp_2) and the collision is reported under app_reservedKeyCollisions rather than being silently clobbered. Prefer not to send reserved names at all.

Custom metrics

Add domain-level metrics with the metrics singleton:

typescript
import { defineApiHandler } from '@j0nathan-ll0yd/validation'
import { metrics, MetricUnit } from '@j0nathan-ll0yd/observability'

const api = defineApiHandler({ schema: SyncSchema, auth: 'bearer', operationName: 'SyncData' })
export const handler = api(async ({ context, body }) => {
  const count = await DataQueries.upsertBatch(body.items)

  metrics.addMetric('ItemsSynced', MetricUnit.Count, count)

  return buildValidatedResponse(context, 200, { synced: count }, ResponseSchema)
})

Custom metrics are published in the same finally flush as the automatic ones.

X-Ray tracing

Each invocation is wrapped in an X-Ray span named after operationName. EventBridge handlers automatically annotate the span with detailType and eventSource.

Add custom subsegments for expensive operations:

typescript
import { getTracer } from '@j0nathan-ll0yd/observability'

const tracer = getTracer()

const api = defineApiHandler({ auth: 'bearer', operationName: 'ProcessData' })
export const handler = api(async ({ context }) => {
  const segment = tracer.getSegment()
  const subsegment = segment?.addNewSubsegment('fetchExternalData')
  try {
    const data = await fetchFromExternalApi()
    subsegment?.close()
    return buildValidatedResponse(context, 200, { processed: data.length }, ResponseSchema)
  } catch (error) {
    subsegment?.addError(error as Error)
    subsegment?.close()
    throw error
  }
})

Traces appear in the AWS console under X-Ray > Traces — latency, downstream calls (DSQL, S3, SQS), and error annotations are all visible there.

Package exports

Always import from @j0nathan-ll0yd/observability, never from the underlying Powertools packages directly.

ExportDescription
loggerConfigured Powertools Logger singleton
metricsConfigured Powertools Metrics singleton
MetricUnitCloudWatch metric unit enum
getTracer()Returns the configured Powertools Tracer
getCurrentSpan()Returns the current active span
logError(message, error)Log a structured error entry
logInfo(message, data?)Log a structured info entry
resolveLoggingConfig()Returns the active logging configuration
sanitizeData(data)Redact sensitive fields before logging
typescript
// CORRECT
import { logger, metrics, MetricUnit } from '@j0nathan-ll0yd/observability'

// FORBIDDEN
import { Logger } from '@aws-lambda-powertools/logger'
import { Metrics } from '@aws-lambda-powertools/metrics'

Environment variables

The Lambda module sets these automatically — no manual configuration needed:

VariableSourceDescription
METRICS_NAMESPACEmantle.config.ts nameCloudWatch metrics namespace
OTEL_SERVICE_NAMELambda function nameOpenTelemetry service name
LOG_LEVELTerraform variableLogger level (INFO, DEBUG, WARN, ERROR)
ENVIRONMENTTerraform variableDeployment stage (dev, staging, prod)

Singletons use a lazy Proxy — environment variables are only read on first access, not at import time. This is safe for Lambda cold starts.

Next steps