Skip to content

feat(notify): delivery observability via ctx.log + ctx.metrics - #183

Merged
prisis merged 11 commits into
alphafrom
feat/notify-delivery-observability
Jul 24, 2026
Merged

feat(notify): delivery observability via ctx.log + ctx.metrics#183
prisis merged 11 commits into
alphafrom
feat/notify-delivery-observability

Conversation

@prisis

@prisis prisis commented Jul 24, 2026

Copy link
Copy Markdown
Member

What

Makes @lunora/notify deliveries observable through the pillars that already have durable storage and Studio surfaces — no bespoke store, admin RPC, or Studio tab. This is "Tier 1" from the design discussion, reusing the structured log sink (#147/#154/#155) and durable metric history (#149/#158) rather than a parallel D1 table.

Design cross-checked against Novu's own approach (they migrated their activity feed to a batched, TTL'd ClickHouse event/rollup pipeline — the same shape as our log sink + metric history), and its status vocabulary + "why 0 sent" reasons are borrowed here.

Changes

@lunora/notify facade — threads the request's ctx.log / ctx.metrics in via new optional CreateNotifyOptions.log / .metrics:

  • notify.send counter on push (deliver), the chat/in-app/webhook helpers, and multi-channel send() — dimensions channel / provider / status, all low-cardinality.
  • log.warn only on failed, carrying error + (push) subscriptionId/userId — trace-correlated, durably archived. Successes/prunes stay off the log.
  • Status vocabulary accepted | failed | gone — honest to edge push (Web Push/FCM give no delivery/open receipts, so no delivered/opened).
  • notify.skipped counter — the "sent 0 because…" signal: no-subscriptions-matched, channel-not-configured.
  • Structural NotifyLogger / NotifyMetrics (the D1Like pattern) avoid an @lunora/server dependency edge. No new store ⇒ bounded retention rides the existing sampled/TTL'd pipelines.

@lunora/codegen — relocates the createNotify(...) build to after log/metrics are in scope and passes { log, metrics }. Non-notify apps get zero churn; golden fixture + notify-demo regenerated (2-line diffs).

Scope boundary

Web Push and FCM provide no delivery/open callbacks, so the feed truthfully reports only accepted / failed / gone at send time. A filterable per-delivery feed / per-device history (Novu-style "activity" drill-down) is deliberately out of scope (would need a field-level predicate on the log reader or a dedicated store) and can follow if demanded.

Verification

  • @lunora/notify: 44/44 tests (7 new observability cases) · lint:types clean · eslint clean
  • @lunora/codegen: 142/142 tests · lint:types clean
  • prettier clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added notification delivery observability with request-scoped ctx.metrics and ctx.log, including low-cardinality notify.send (channel, provider, status: accepted, failed, gone) and notify.skipped (channel, reason: no-subscriptions-matched, channel-not-configured).
    • accepted reflects provider acceptance (not delivery/open receipts); failed sends also emit warning logs with delivery context.
    • Added public typing for delivery status, skip reasons, and notify logger/metrics contracts.
  • Documentation
    • Updated observability docs with “Delivery metrics (notify)” and clarified delivery/skip semantics.
  • Tests
    • Added/expanded benchmarks for notification and metric observability overhead, plus a test:bench script.

prisis and others added 3 commits July 24, 2026 09:47
Thread the request's ctx.log / ctx.metrics into the notify facade (new
optional CreateNotifyOptions.log / .metrics) so every send emits into the
durable log + metric pipelines instead of a bespoke store:

- notify.send counter on push, chat/in-app/webhook, and multi-channel send,
  dimensioned by channel / provider / status (low-cardinality).
- log.warn only on failure, carrying error + push subscription/user ids
  (trace-correlated, durably archived).
- Stable status vocabulary accepted | failed | gone — honest to edge push
  (Web Push / FCM give no delivery/open receipts).
- notify.skipped counter for the "sent 0 because…" signal
  (no-subscriptions-matched, channel-not-configured).

Bounded retention is satisfied by NOT adding a store — everything rides the
existing sampled/TTL'd log + metric history. Structural NotifyLogger /
NotifyMetrics avoid an @lunora/server dependency edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU
Relocate the createNotify build fragment to after log/metrics are in scope in
the shard context builder and pass { log, metrics }, so the generated ctx.notify
emits the notify.send / notify.skipped observability signals. Non-notify apps
are unaffected (empty fragment); golden fixture + assertion updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU
Regenerate the notify-demo example's _generated/shard.ts after the createNotify
codegen change. Generated output only; not test-gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU
@netlify

netlify Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy Preview for lunorash ready!

Name Link
🔨 Latest commit c89b0fa
🔍 Latest deploy log https://app.netlify.com/projects/lunorash/deploys/6a63a08ed8afa3000892b5f6
😎 Deploy Preview https://deploy-preview-183--lunorash.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for following the naming conventions! 🙏

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for confirming the Contributor License Agreement! 🙏

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Notification delivery now exposes typed observability contracts, records send and skip outcomes across notification paths, logs failures, and wires request-scoped logging and metrics into generated notify contexts. Documentation and benchmarks cover the resulting behavior.

Changes

Notify delivery observability

Layer / File(s) Summary
Observability contracts
packages/notify/src/types.ts, packages/notify/src/index.ts
Adds and re-exports delivery-status, skip-reason, logger, and metrics types.
Notify outcome recording
packages/notify/src/notify.ts
Maps receipts, records send and skip metrics, logs failed deliveries, and updates push subscription state across push and channel sends.
Request-context wiring and documentation
packages/codegen/src/emit.ts, packages/notify/README.md, apps/docs/src/content/docs/concepts/observability.mdx
Builds notify with request-scoped logging and metrics and documents delivery observability semantics and receipt limitations.
Observability benchmark coverage
packages/notify/__bench__/*, packages/do/__bench__/*, packages/notify/package.json, packages/notify/tsconfig.json, packages/notify/vitest.bench.config.ts, packages/notify/eslint.config.js
Adds benchmarks for notify broadcast instrumentation, metric emission and persistence, and log-field normalization, with package benchmark configuration and inclusion updates.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NotifyFacade
  participant DeliveryProvider
  participant SubscriptionStore
  participant CtxMetrics
  participant CtxLog
  NotifyFacade->>DeliveryProvider: send notification
  DeliveryProvider-->>NotifyFacade: return receipt
  NotifyFacade->>SubscriptionStore: update subscription status
  NotifyFacade->>CtxMetrics: count notify.send or notify.skipped
  NotifyFacade->>CtxLog: warn on failed delivery
Loading

Possibly related PRs

Suggested labels: package: testing

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, conventional-commit style, and accurately summarizes the main notify observability change.
Description check ✅ Passed The description covers the change, scope, and verification clearly, even though it doesn't follow the template headings exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/notify-delivery-observability

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 157 skipped benchmarks1


Comparing feat/notify-delivery-observability (2ca32f7) with alpha (7acf42b)2

Open in CodSpeed

Footnotes

  1. 157 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on alpha (a07c091) during the generation of this report, so 7acf42b was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Add a "Delivery metrics (notify)" section to the observability concept doc and
a "Delivery observability" section to the notify README covering the
notify.send / notify.skipped series, the accepted|failed|gone status vocabulary,
and the failure log line — plus the honest no-delivery-receipt boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/notify/src/notify.ts (1)

14-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Constrain skip reasons to the exported vocabulary.

observeSkip accepts any string, allowing metric-label drift outside NotifySkipReason.

Proposed change
     NotifyLogger,
     NotifyMetrics,
+    NotifySkipReason,
 
-    const observeSkip = (channel: string, reason: string): void => {
+    const observeSkip = (channel: string, reason: NotifySkipReason): void => {

As per path instructions, ensure proper TypeScript types.

Also applies to: 212-215

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/notify/src/notify.ts` around lines 14 - 17, Update observeSkip to
accept only the exported NotifySkipReason type instead of an arbitrary string,
importing that type where needed. Apply the same TypeScript constraint to both
observeSkip declarations or usages identified in the diff, while preserving
existing metric behavior.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/notify/src/notify.ts`:
- Around line 313-315: The receipt status handling in notify must preserve gone
for push receipts: update the loop around observeSend to use
pushDeliveryStatus(receipt, receiptError(receipt)) when receipt.channel is
"push", while retaining the accepted/failed mapping for other channels. In
packages/notify/README.md lines 103-110 and
apps/docs/src/content/docs/concepts/observability.mdx lines 379-394, clarify
that automatic pruning applies only to stored ctx.push.send/broadcast
subscriptions; generic direct push targets may report gone but cannot be pruned.

---

Nitpick comments:
In `@packages/notify/src/notify.ts`:
- Around line 14-17: Update observeSkip to accept only the exported
NotifySkipReason type instead of an arbitrary string, importing that type where
needed. Apply the same TypeScript constraint to both observeSkip declarations or
usages identified in the diff, while preserving existing metric behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 51a52e81-c078-4fe5-8a80-c4a08924a3e9

📥 Commits

Reviewing files that changed from the base of the PR and between a07c091 and 9470fa7.

⛔ Files ignored due to path filters (4)
  • examples/notify-demo/lunora/_generated/shard.ts is excluded by !**/_generated/** and included by none
  • packages/codegen/__tests__/fixtures/simple/expected/_generated/shard.ts is excluded by !**/_generated/**, !**/__tests__/** and included by packages/**
  • packages/codegen/__tests__/run-codegen.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
  • packages/notify/__tests__/notify.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
📒 Files selected for processing (6)
  • apps/docs/src/content/docs/concepts/observability.mdx
  • packages/codegen/src/emit.ts
  • packages/notify/README.md
  • packages/notify/src/index.ts
  • packages/notify/src/notify.ts
  • packages/notify/src/types.ts

Comment on lines +313 to +315
for (const receipt of receipts) {
observeSend(receipt.channel ?? "unknown", receipt.provider, receipt.successful ? "accepted" : "failed", { error: receiptError(receipt) });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Preserve gone for generic push receipts.

ctx.notify.send() labels all failed push receipts as failed, bypassing pushDeliveryStatus; unregistered endpoints therefore disappear from the gone metric series.

  • packages/notify/src/notify.ts#L313-L315: for receipt.channel === "push", derive status with pushDeliveryStatus(receipt, receiptError(receipt)); retain accepted/failed mapping for other channels.
  • packages/notify/README.md#L103-L110: scope automatic pruning to stored ctx.push.send/broadcast subscriptions; generic direct push targets can be reported as gone but cannot be pruned.
  • apps/docs/src/content/docs/concepts/observability.mdx#L379-L394: make the same pruning distinction.
Proposed status mapping
 for (const receipt of receipts) {
-    observeSend(receipt.channel ?? "unknown", receipt.provider, receipt.successful ? "accepted" : "failed", { error: receiptError(receipt) });
+    const error = receiptError(receipt);
+    const status =
+        receipt.channel === "push"
+            ? pushDeliveryStatus(receipt, error)
+            : receipt.successful
+              ? "accepted"
+              : "failed";
+
+    observeSend(receipt.channel ?? "unknown", receipt.provider, status, { error });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const receipt of receipts) {
observeSend(receipt.channel ?? "unknown", receipt.provider, receipt.successful ? "accepted" : "failed", { error: receiptError(receipt) });
}
for (const receipt of receipts) {
const error = receiptError(receipt);
const status =
receipt.channel === "push"
? pushDeliveryStatus(receipt, error)
: receipt.successful
? "accepted"
: "failed";
observeSend(receipt.channel ?? "unknown", receipt.provider, status, { error });
}
📍 Affects 3 files
  • packages/notify/src/notify.ts#L313-L315 (this comment)
  • packages/notify/README.md#L103-L110
  • apps/docs/src/content/docs/concepts/observability.mdx#L379-L394
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/notify/src/notify.ts` around lines 313 - 315, The receipt status
handling in notify must preserve gone for push receipts: update the loop around
observeSend to use pushDeliveryStatus(receipt, receiptError(receipt)) when
receipt.channel is "push", while retaining the accepted/failed mapping for other
channels. In packages/notify/README.md lines 103-110 and
apps/docs/src/content/docs/concepts/observability.mdx lines 379-394, clarify
that automatic pruning applies only to stored ctx.push.send/broadcast
subscriptions; generic direct push targets may report gone but cannot be pruned.

The codegen ctx-splice, notify_send_outside_action advisor lint, and Studio
Notifications page listed as "remaining" are all shipped; the referenced plan
file is gone. Rewrite Status to reflect what's shipped (incl. delivery
observability) and note the deferred per-delivery activity feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU
Address thermo review findings:
- Pin the exact provider dimension on the channel-send metric assertion
  (was objectContaining, hiding the receipt-provider fallback).
- Add a multi-channel notify.send test covering the per-receipt loop and
  the `?? "unknown"` channel/provider fallback (only reachable via an
  unlabeled receipt) plus the failure warn on that path.
- Trim a redundant inline comment that restated observeSend's JSDoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU
Add CodSpeed benches for the observability critical path, motivated by a trace
finding: every ctx.metrics.count does a synchronous SQLite select+upsert+prune
per call (recordMetricHistory in ShardDOBase.recordMetric), so a broadcast that
emits notify.send per recipient pays that cost N times.

- do/__bench__/normalize-log-fields.bench.ts — the fn every metric attr + log
  field routes through (flat/nested/merge/empty).
- do/__bench__/metrics-emit.bench.ts — ctx.metrics.count CPU-only vs durable;
  the durable path measures ~112x the CPU cost (~19us/call).
- notify/__bench__/broadcast-observability.bench.ts — facade overhead,
  instrumented vs no-handles, ok vs fail; confirms the facade adds ~0 (the cost
  is downstream in the per-count SQLite write).

Wires notify's test:bench script + vitest.bench.config.ts and adds __bench__ to
its tsconfig include.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU
Each ctx.metrics.count is a synchronous SQLite upsert (~19us — see the
metrics-emit bench), so emitting notify.send per recipient made a broadcast pay
that durable write O(N) times. Fold a broadcast's outcomes into one count per
(kind, status) bucket — at most kinds×3 emits — with the bucket total as the
metric value.

- deliver() returns { receipt, status } and counts inline only for a single
  push.send; a broadcast passes countInline=false and emits aggregated buckets.
- Failure LOGS stay per-recipient (no durable write; they carry the ids).
- Split observeSend into countSend (with a count arg) + warnFailedSend, the
  latter called only inside a failed guard so its fields never allocate on the
  hot success path.
- BroadcastResult shape unchanged; reuses the derived status instead of
  re-deriving via isGoneError.

Adds a test asserting a mixed 6-recipient broadcast emits exactly 3 aggregated
counts + 2 per-recipient warns. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU
The new bench config sits at the package root, outside the tsconfig project, so
type-aware eslint failed to parse it ("parserOptions.project ... not found").
Add it to the ignores list next to vitest.config.ts, matching @lunora/do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU
@prisis
prisis merged commit e01d444 into alpha Jul 24, 2026
36 of 38 checks passed
@prisis
prisis deleted the feat/notify-delivery-observability branch July 24, 2026 17:28
prisis added a commit that referenced this pull request Jul 24, 2026
Sync the cloud observability + security-hardening branch with alpha (OTLP
resource attributes #182, notify observability #183, do perf #184, workerd
custom-spans test #180, ai-gateway correlation #181, and release bumps).
Clean auto-merge — no conflicts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant