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]
Worker
Section titled “Worker”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).
Workflows
Section titled “Workflows”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
Section titled “Activities”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.
React Email templates
Section titled “React Email templates”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.
Data access
Section titled “Data access”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.
Observability
Section titled “Observability”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 bybuild.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
exportersink (makeWorkflowExporter) with its own span processor, not through the Node tracer provider. - An
UndiciInstrumentationproduces thefetchCLIENT 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 thex-huddle-org-idheader — byte-for-byte the same key the API’s propagator writes and RAG reads — and stampsorg.id+workflow.typeon 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 bytemplate,status(sent/error), and the currentworkflow/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_ENDPOINT—temporalRuntimeOptionspasses the endpoint explicitly (with a/v1/metricspath) and mirrors the resource attributes as global tags sotemporal_*series carry the sameservice_name/deployment_environment/instancelabels as everything else.