Skip to content

Architecture

Notification is a Temporal worker. The Go API is the client that starts these workflows (e.g. when a user is invited, requests an OTP, or a bond request is shared); the worker runs them and sends the email.

graph LR
    API[Go API<br/>Temporal client] -->|start workflow| Q[notification<br/>task queue]
    Q --> W[Notification worker]
    W --> WF[Workflow<br/>thin wrapper]
    WF --> ACT[Activity]
    ACT -->|fetch data| SDK[@huddlesurety/api]
    ACT -->|render + send| RE[React Email → Resend]

src/worker.ts connects to Temporal (namespace = deployment environment) and polls the notification task queue. Concurrency is intentionally small (caps of 10, --max-old-space-size=256).

src/workflows.ts defines three thin workflows. Each just invokes its activity through proxyActivities (shared policy: 1-minute start-to-close timeout, up to 5 retries).

Workflow Arg(s) Activity
SendOTPWorkflow userID, code, expiresAt sendOTP
SendInviteWorkflow inviteID sendInvite
SendBondRequestWorkflow formID sendBondRequest

Activities (src/activities/) are the side-effecting half: each fetches the data it needs from the API, dynamically imports its template, and sends via Resend. On a Resend error it throws so Temporal retries.

  • sendOTP — fetches the user, emails a verification code.
  • sendInvite — fetches the invite, the target org, and the inviter; emails the invitee an invite link.
  • sendBondRequest — fetches the bond request (api.bond.bondGetRequest) and the sender org, lists that org’s assignments, resolves each assignee’s email, and sends to all of them.

The barrel (activities/index.ts) re-exports all three; worker.ts imports them as * as activities.

Templates live in src/templates/. Each activity renders its template by passing the component invocation to Resend’s react: field — Resend does the server-side render to HTML. Templates are pulled in via dynamic import() inside the activity, so the .tsx/React code stays out of the workflow bundle and loads only when the activity runs.

  • components/layout.tsx — the shared <Layout> (brand logo, fonts, footer) wrapping every email, built with @react-email/components + its Tailwind primitive.
  • send-otp.tsx, send-invite.tsx, send-bond-request.tsx — the three emails. Each also exports a default zero-arg example with sample props, which is what the preview server renders.

src/lib/api.ts is the single integration point with the Go API — no activity imports the SDK directly. It builds a HuddleAPI client with a custom fetcher that injects Authorization: Bearer <API_AUTH_KEY> and surfaces the API’s error message on non-2xx responses. API_URL points at the API’s internal Railway address in production and localhost for local dev.

The worker is instrumented with OpenTelemetry (src/o11y/), so it shows up in the shared Grafana stack as a first-class service alongside the API and RAG. Everything is gated on OTEL_EXPORTER_OTLP_ENDPOINT — with the endpoint unset (local dev), setupOtel no-ops and the worker runs untraced.

Traces. A NodeTracerProvider exports spans over OTLP, and the worker joins the API’s distributed trace across the Temporal boundary exactly the way RAG does:

  • The OTel workflow interceptors (o11y/workflow-interceptors.ts, baked into the bundle by build.ts) extract the caller’s trace context from the workflow start header the API injects, and re-inject it into activity scheduling — so activities continue the API’s trace instead of rooting a fresh one.
  • Because workflow spans originate inside Temporal’s sandbox, they’re exported through a worker exporter sink (makeWorkflowExporter) with its own span processor, not through the Node tracer provider.
  • An UndiciInstrumentation produces the fetch CLIENT spans that form the Notification → API edge in the service graph (the servicegraph connector needs a CLIENT span to pair with the API’s SERVER span; trace propagation alone doesn’t create the edge).
  • IdentityActivityInboundInterceptor (o11y/identity.ts) reads the x-huddle-org-id header — byte-for-byte the same key the API’s propagator writes and RAG reads — and stamps org.id + workflow.type on the active activity span, keeping traces and span-metrics sliceable by tenant and workflow.

Metrics. A MeterProvider exports on a 15 s interval (traces flush faster, 5 s, so cross-service traces assemble promptly in Tempo). It emits:

  • notification.emails.sent — a counter of emails handed to Resend, labelled by template, status (sent/error), and the current workflow/activity.
  • Host metrics (@opentelemetry/host-metrics).
  • Temporal Core runtime metrics (task-slot and poll-latency series). Temporal Core (Rust) has its own OTLP exporter that does not read OTEL_EXPORTER_OTLP_ENDPOINTtemporalRuntimeOptions passes the endpoint explicitly (with a /v1/metrics path) and mirrors the resource attributes as global tags so temporal_* series carry the same service_name / deployment_environment / instance labels as everything else.