Skip to main content

Overview

Sightscreen’s observability stack covers three layers:
  1. Structured logging — pino on the backend, os.Logger + AppLogger on iOS
  2. Error tracking — Sentry on both platforms, with a pino-to-Sentry bridge on the backend
  3. Distributed tracing — Sentry transactions on iOS (LATracer) and component tags on the backend for CloudWatch filtering
All backend logs flow to stdout as JSON and are captured by AWS CloudWatch Logs via App Runner.

Pino structured logging

The root logger is created in src/utils/logger.ts and exported as a singleton. Every other module imports it.

Environment variables

VariableDefaultPurpose
LOG_LEVELinfoMinimum pino log level (trace, debug, info, warn, error, fatal)
NODE_ENVWhen development, pino-pretty is used for human-readable output

Output modes

{"level":30,"time":1711612345678,"msg":"request completed","method":"GET","path":"/v1/matches","statusCode":200,"responseTime":12,"requestId":"a1b2c3d4"}
pino-pretty is an optional dev dependency. If it is not installed, the logger silently falls back to JSON output even in development.

Child logger conventions

Child loggers carry contextual fields that appear in every log line from that scope.
// Request-scoped (created by requestLogger middleware)
req.log = logger.child({ requestId });

// Job-scoped
const jobLog = logger.child({ job: 'MatchOrchestrator' });

// Provider-scoped
const providerLog = logger.child({ provider: 'SportMonks' });

// Component-scoped (for CloudWatch filtering)
const laLog = logger.child({ matchId, component: 'live-activity' });

Error logging pattern

Always pass errors under the err key so pino serializes the full stack trace:
// Correct -- pino extracts message + stack
log.error({ err: error }, 'failed to send push notification');

// Wrong -- loses stack trace
log.error(`failed: ${error.message}`);

Request logging middleware

The requestLogger middleware in src/middleware/logging.ts runs on every request:
  1. Generates a requestId (UUIDv4) and attaches it to req.requestId
  2. Creates a child logger at req.log scoped to that request
  3. On response finish, logs the request with method, path, status code, and response time
  4. Routes the log to the appropriate level based on status code:
    • >= 500error
    • >= 400warn
    • otherwise — info
The companion errorLogger middleware catches unhandled errors and logs them with { err } before passing to the next error handler.

Sentry (backend)

Sentry is opt-in — it only initializes when SENTRY_DSN is set.

Initialization

src/instrument.ts is imported before anything else in the entry point. It calls Sentry.init() with the DSN and a 100% trace sample rate.
if (SENTRY_DSN) {
  Sentry.init({
    dsn: SENTRY_DSN,
    sendDefaultPii: true,
    tracesSampleRate: 1.0,
  });
}

Express error handler

After all routes are registered, Sentry.setupExpressErrorHandler(app) is called. This captures unhandled Express errors as Sentry exceptions automatically.

Pino-to-Sentry stream

The logger uses pino.multistream to tee output to both stdout and a custom Sentry writable stream:
  • The Sentry stream only processes logs at level >= 50 (error and fatal)
  • If the log contains an err object with message and stack, it calls Sentry.captureException
  • Otherwise it calls Sentry.captureMessage with the appropriate severity
  • The stream is wrapped in a try/catch so Sentry failures never break logging

Graceful shutdown

On SIGTERM or SIGINT, the shutdown handler calls Sentry.close(2000) with a 2-second timeout to flush any pending events before the process exits.
sendDefaultPii: true is currently set without a beforeSend scrubber. This means Sentry may capture IP addresses, cookies, and other PII from request headers. See Known issues below.

Sentry (iOS)

The iOS app uses sentry-cocoa SDK, configured in SightscreenApp.configureSentry().

Configuration

SettingValueNotes
tracesSampleRate1.0100% of transactions are sampled
tracePropagationTargets["api.sightscreen.app", "localhost"]Auto-injects sentry-trace header on requests to these hosts
attachScreenshottrueCaptures a screenshot on every error event
enableAppHangTrackingtrueDetects and reports main-thread hangs
enableMetricKittrueForwards MetricKit diagnostics to Sentry

AppLogger

AppLogger mirrors pino’s child logger pattern on iOS. It is the single entry point for all logging.
let log = AppLogger.root.child("FollowManager")
log.info("follow match", extra: ["matchId": matchId])
log.error("failed", error: error, extra: ["matchId": matchId])

// Nested child loggers
let childLog = log.child("TokenSync")  // category: "FollowManager.TokenSync"
Backends:
  • os.Logger — always active. Visible in Xcode console and Console.app
  • SentryBackend — device builds only (disabled on simulator). Routes errors to SentrySDK.capture and all other levels to breadcrumbs
  • DebugLogService — admin overlay, enabled via DebugLogSettings

SentryBackend

SentryLogger.swift isolates the Sentry import behind a SentryBackend enum:
  • captureError — calls SentrySDK.capture(error:) or SentrySDK.capture(message:), attaching the logger category as a tag and any extras
  • addBreadcrumb — logs non-error events as Sentry breadcrumbs with category and level mapping
  • setGlobalContext — attaches device context (OS version, device model, app version, build number, vendor device ID, locale) under the "app" scope key
  • setLiveActivityStatus — records LA authorization and frequent-push capability under the "live_activity" scope key
SentryBackend.isAvailable returns false on the simulator via #if targetEnvironment(simulator). This avoids noisy local development events.

Distributed tracing

LATracer (iOS)

LATracer creates Sentry transactions for the follow-to-Live-Activity lifecycle:
let tracer = LATracer(matchId: matchId)

// Root transaction
tracer.startFollow(isLive: true)  // transaction: "la.follow", operation: "la.lifecycle"

// Child spans for each step
tracer.startSpan(LATracer.opAPICall, description: "POST /v1/follow")
// ... API call completes
tracer.finishSpan(LATracer.opAPICall)

tracer.startSpan(LATracer.opLAStart, description: "ActivityKit request")
// ... LA starts
tracer.finishSpan(LATracer.opLAStart)

// Finish the root transaction
tracer.finish()
Span operation constants:
ConstantValueDescription
opAPICallla.api.followFollow API request
opAPIUnfollowla.api.unfollowUnfollow API request
opLAStartla.activity.startActivityKit start request
opLAAdoptla.activity.adoptAdopt existing activity
opTokenRefreshla.token.refreshPush token refresh cycle

Trace propagation

The iOS SDK auto-injects sentry-trace and baggage headers on outgoing requests to hosts matching tracePropagationTargets:
api.sightscreen.app
localhost
This links iOS transactions to backend spans in the Sentry trace view.

Backend component tags

The backend tags Live Activity-related logs with component: 'live-activity' for CloudWatch filtering:
logger.child({ matchId, component: 'live-activity' }).info('sending push-to-start');

CloudWatch integration

App Runner captures all stdout output and forwards it to CloudWatch Logs. Since pino outputs JSON in production, you can use CloudWatch Insights filter patterns:
# All live-activity logs
{ $.component = "live-activity" }

# Errors only
{ $.level >= 50 }

# Specific match
{ $.matchId = "12345" }

# Slow requests (> 1 second)
{ $.responseTime > 1000 }
No custom CloudWatch agent or log driver is required. App Runner’s built-in log capture handles everything as long as logs go to stdout.

Known issues

instrument.ts sets sendDefaultPii: true which tells Sentry to capture request headers, cookies, and IP addresses. There is currently no beforeSend hook to strip sensitive fields.Risk: User IP addresses and session cookies may appear in Sentry events.Fix: Add a beforeSend callback that strips or redacts sensitive headers, or disable sendDefaultPii entirely.
The Cognito auth middleware logs the decoded JWT claims at debug level, which can include the user’s email address. If LOG_LEVEL is set to debug in production, these will appear in CloudWatch and potentially in Sentry via the pino-to-Sentry stream.Risk: PII in log storage.Fix: Redact email from claims before logging, or only log the sub claim.
Some error paths in the iOS AppConfigService call both AppLogger.error() (which routes to Sentry) and directly call SentrySDK.capture. This results in duplicate Sentry events for the same error.Fix: Remove direct SentrySDK.capture calls and rely solely on AppLogger.error() for Sentry routing.