Skip to content

feat(billing): time-based cutover for HTTP↔S3 event-name suffix swap#6705

Open
pfreixes wants to merge 4 commits into
masterfrom
pau/nan-6144-http-event-suffix
Open

feat(billing): time-based cutover for HTTP↔S3 event-name suffix swap#6705
pfreixes wants to merge 4 commits into
masterfrom
pau/nan-6144-http-event-suffix

Conversation

@pfreixes

@pfreixes pfreixes commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds BILLING_EVENTS_CUTOVER_AT — an ISO 8601 timestamp that gates the S3-fed pipeline going authoritative for billing. Before that instant, S3 events ship suffixed and HTTP events canonical; after, S3 events become canonical and HTTP events pick up an _http suffix as defensive shadow.
  • Adds BILLING_EVENTS_HTTP_EVENT_NAME_SUFFIX — the suffix value applied to HTTP events post-cutover.
  • Wires the check into the two emission chokepoints: toOrbEvent for the HTTP-fed path and billingEventsS3Export.ts for the S3-fed daily export.

Together, this means both env PRs can merge ahead of the cutover moment and the pods auto-swap at the configured instant with no manual deploy needed.

Context

Prep work for NAN-6144. The time-based mechanism was suggested by @TBonnin in the earlier round of review on this PR — same PR now implements that suggestion.

Rollback if something goes wrong post-cutover: unset (or set to a future date) BILLING_EVENTS_CUTOVER_AT and redeploy. The suffix values in the sibling env vars stay in place so no config surface disappears during rollback.

Malformed timestamps fail at Zod parse time (z.string().datetime()) so a typo boots-fails the pod instead of silently deferring the cutover indefinitely.

Test plan

  • Two unit tests: one for post-cutover (past date → suffix applied), one for pre-cutover (future date → no suffix). See adapters.unit.test.ts.
  • Existing tests still cover the "no cutover date set" default (unsuffixed).
  • npm run ts-build, npm run lint, npm run format:check clean.
  • After deploy to dev metering (with CUTOVER_AT=2026-07-09 per companion env PR): confirm dev Orb receives _http-suffixed HTTP events + unsuffixed S3 events, canonical billable metrics keep counting.
  • After deploy to prod (still pre-cutover date on merge): confirm no behavior change — prod continues shipping HTTP canonical + S3 _s3.

🤖 Generated with Claude Code

Adds BILLING_EVENTS_HTTP_EVENT_NAME_SUFFIX, an optional suffix appended
to the Orb event_name emitted by the HTTP-fed billing path. Default is
empty so behavior is unchanged in every environment where the var is
not set.

Prepares the ground for the S3-fed cutover: once dev flips this to
"_http" (and clears the S3 suffix), HTTP-emitted events keep flowing
into Orb under a distinct name and remain queryable via
/prices/evaluate as a defensive backup, without competing with the
S3-emitted events for the canonical billable-metric filter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 7, 2026

Copy link
Copy Markdown

NAN-6144

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No issues found across 3 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@pfreixes pfreixes requested review from a team and TBonnin July 7, 2026 14:00
}

return {
event_name: event.type,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we make the cut-over time-based?

const http_suffix = today >= Aug1stMidnight ? 'http' : '';

// reversed for the s3 export suffix

Then you don't have anything to do, just be in front of your computer and confirm correct events are being injested

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good call, but still I might want to have a flag for disabling it just in case .... let me work on this

Introduces BILLING_EVENTS_CUTOVER_AT — an ISO 8601 timestamp that flips
which pipeline emits events under the canonical (unsuffixed) name
customer billable metrics filter on:

- Before the timestamp (or when it's unset): S3 events keep their
  pre-cutover suffix (BILLING_EVENTS_S3_EVENT_NAME_SUFFIX); HTTP events
  stay unsuffixed and continue to feed customer plans.
- At and after the timestamp: S3 events become unsuffixed (canonical);
  HTTP events pick up BILLING_EVENTS_HTTP_EVENT_NAME_SUFFIX ("_http" per
  the deploy config) and become the defensive shadow.

Deployment can now merge and roll out ahead of the cutover date and the
suffix swap happens automatically at the configured instant. Rollback =
unset (or clear, or push to a future date) the env var and redeploy.

Malformed dates fail hard at Zod parse time via z.string().datetime() so
a typo can't silently defer the cutover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@pfreixes pfreixes changed the title feat(billing): add HTTP event-name suffix env var feat(billing): time-based cutover for HTTP↔S3 event-name suffix swap Jul 9, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/utils/lib/environment/parse.ts">

<violation number="1" location="packages/utils/lib/environment/parse.ts:368">
P2: Valid ISO 8601 cutover timestamps with explicit offsets can make the service fail env validation, because `z.string().datetime()` only accepts `Z` timestamps by default. Allowing offsets keeps the env var aligned with the documented ISO 8601 format operators are likely to use.</violation>
</file>

<file name="packages/billing/lib/clients/orb/adapters.ts">

<violation number="1" location="packages/billing/lib/clients/orb/adapters.ts:16">
P3: The `cutoverActive()` cutover-active check is duplicated across two packages (`billing` and `metering`). If the cutover comparison ever needs to change — logging, timezone handling, grace-period logic, or date format — both copies must be updated in sync. Consider extracting a shared helper into `@nangohq/utils` (or a billing-adjacent shared module) so the two packages share one source of truth.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/metering/lib/crons/billingEventsS3Export.ts Outdated
// and after this instant, the two swap roles. Unset (or set to a
// future date) to defer or roll back the cutover. Remove once the
// HTTP emission path is retired.
BILLING_EVENTS_CUTOVER_AT: z.string().datetime().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Valid ISO 8601 cutover timestamps with explicit offsets can make the service fail env validation, because z.string().datetime() only accepts Z timestamps by default. Allowing offsets keeps the env var aligned with the documented ISO 8601 format operators are likely to use.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/utils/lib/environment/parse.ts, line 368:

<comment>Valid ISO 8601 cutover timestamps with explicit offsets can make the service fail env validation, because `z.string().datetime()` only accepts `Z` timestamps by default. Allowing offsets keeps the env var aligned with the documented ISO 8601 format operators are likely to use.</comment>

<file context>
@@ -349,11 +349,23 @@ export const ENVS = z.object({
+    // and after this instant, the two swap roles. Unset (or set to a
+    // future date) to defer or roll back the cutover. Remove once the
+    // HTTP emission path is retired.
+    BILLING_EVENTS_CUTOVER_AT: z.string().datetime().optional(),
     BILLING_EVENTS_S3_REGION: z.string().optional().default('us-west-2'),
 
</file context>
Suggested change
BILLING_EVENTS_CUTOVER_AT: z.string().datetime().optional(),
BILLING_EVENTS_CUTOVER_AT: z.string().datetime({ offset: true }).optional(),

// HTTP-emitted events stay unsuffixed so customer billable metrics keep
// matching them. Once it flips true, HTTP events pick up the "_http"
// suffix and become the defensive shadow.
function cutoverActive(): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The cutoverActive() cutover-active check is duplicated across two packages (billing and metering). If the cutover comparison ever needs to change — logging, timezone handling, grace-period logic, or date format — both copies must be updated in sync. Consider extracting a shared helper into @nangohq/utils (or a billing-adjacent shared module) so the two packages share one source of truth.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/billing/lib/clients/orb/adapters.ts, line 16:

<comment>The `cutoverActive()` cutover-active check is duplicated across two packages (`billing` and `metering`). If the cutover comparison ever needs to change — logging, timezone handling, grace-period logic, or date format — both copies must be updated in sync. Consider extracting a shared helper into `@nangohq/utils` (or a billing-adjacent shared module) so the two packages share one source of truth.</comment>

<file context>
@@ -8,6 +8,15 @@ import { putOrbCustomerSchema } from './types.js';
+// HTTP-emitted events stay unsuffixed so customer billable metrics keep
+// matching them. Once it flips true, HTTP events pick up the "_http"
+// suffix and become the defensive shadow.
+function cutoverActive(): boolean {
+    return !!envs.BILLING_EVENTS_CUTOVER_AT && new Date() >= new Date(envs.BILLING_EVENTS_CUTOVER_AT);
+}
</file context>

Comment thread packages/utils/lib/environment/parse.ts Outdated
// shadow (suffixed) and HTTP events are canonical (unsuffixed). At
// and after this instant, the two swap roles. Unset (or set to a
// future date) to defer or roll back the cutover. Remove once the
// HTTP emission path is retired.

@TBonnin TBonnin Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

not sure you need environment variables for the suffix. they are never gonna be dynamic and always be http | s3. right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, good point. Ill keep the S3 one bc was already there and there was some divergence btw dev an prod, but the HTTP one can be removed and hardcoded

pfreixes and others added 2 commits July 9, 2026 16:47
Per review feedback: the cutover suffixes were never going to vary at
runtime — always "_http" on the HTTP path post-cutover and "_s3" on the
S3 path pre-cutover. Two env vars just for holding two string constants
was unnecessary config surface for a temporary mechanism.

Drops BILLING_EVENTS_S3_EVENT_NAME_SUFFIX (pre-existing) and
BILLING_EVENTS_HTTP_EVENT_NAME_SUFFIX (added earlier in this PR), inlines
the two literals at the emission sites, and updates the unit tests to
key solely on BILLING_EVENTS_CUTOVER_AT — the one env var that genuinely
varies per environment.

Dev's Orb workspace previously had the shadow S3 events named "_poc";
that value is being retired here. Post-deploy, dev shadow-S3 events will
land under "_s3" like prod, so any dev Orb billable-metric filter still
matching on "_poc" needs a one-time filter update. Dev's cutover date is
in the past so the pre-cutover suffix is only briefly observed during
initial deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two suffix checks were reading wall-clock time. That's wrong at the
seam: the Aug 1 00:15 UTC S3 export cron fires for July 31 data, so
under the old check it saw "wall clock past cutover → drop the suffix"
and shipped July 31 rows under the canonical (unsuffixed) name — the
same name customer plans already had HTTP-fed events under for July 31.
Result: July 31 double-counts on the July invoice.

Fixes both sides symmetrically:

- adapters.ts (HTTP path): decision keys on the event's own timestamp,
  so a batched or late-emitted event whose logical time is pre-cutover
  stays unsuffixed regardless of when the flush happens to hit.
- billingEventsS3Export.ts (S3 path): decision keys on the DATA day
  (`yesterdayUTC()` at cron time), so the Aug 1 firing exporting July 31
  correctly ships as "_s3", and the Aug 2 firing exporting Aug 1
  correctly ships unsuffixed.

Adds a seam test that pins the per-event-timestamp semantic: two events
straddling the cutover instant, one before and one after, verified to
produce the right suffix independently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@pfreixes pfreixes requested a review from TBonnin July 10, 2026 09:41

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/metering/lib/crons/billingEventsS3Export.ts">

<violation number="1" location="packages/metering/lib/crons/billingEventsS3Export.ts:27">
P2: S3 events can be shadow-suffixed even when their emitted Orb `timestamp` is already post-cutover. Since this cutover is keyed to event logical time, consider comparing against the same end-of-day timestamp used in `metricRowsSql` rather than the day's midnight boundary.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// See BILLING_EVENTS_CUTOVER_AT in packages/utils.
function dayIsPostCutover(day: string): boolean {
if (!envs.BILLING_EVENTS_CUTOVER_AT) return false;
return new Date(`${day}T00:00:00Z`) >= new Date(envs.BILLING_EVENTS_CUTOVER_AT);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: S3 events can be shadow-suffixed even when their emitted Orb timestamp is already post-cutover. Since this cutover is keyed to event logical time, consider comparing against the same end-of-day timestamp used in metricRowsSql rather than the day's midnight boundary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/metering/lib/crons/billingEventsS3Export.ts, line 27:

<comment>S3 events can be shadow-suffixed even when their emitted Orb `timestamp` is already post-cutover. Since this cutover is keyed to event logical time, consider comparing against the same end-of-day timestamp used in `metricRowsSql` rather than the day's midnight boundary.</comment>

<file context>
@@ -18,14 +18,13 @@ const bucket = envs.BILLING_EVENTS_S3_BUCKET;
+// See BILLING_EVENTS_CUTOVER_AT in packages/utils.
+function dayIsPostCutover(day: string): boolean {
+    if (!envs.BILLING_EVENTS_CUTOVER_AT) return false;
+    return new Date(`${day}T00:00:00Z`) >= new Date(envs.BILLING_EVENTS_CUTOVER_AT);
 }
 
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants