feat(billing): time-based cutover for HTTP↔S3 event-name suffix swap#6705
feat(billing): time-based cutover for HTTP↔S3 event-name suffix swap#6705pfreixes wants to merge 4 commits into
Conversation
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>
| } | ||
|
|
||
| return { | ||
| event_name: event.type, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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
| // 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(), |
There was a problem hiding this comment.
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>
| 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 { |
There was a problem hiding this comment.
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>
| // 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. |
There was a problem hiding this comment.
not sure you need environment variables for the suffix. they are never gonna be dynamic and always be http | s3. right?
There was a problem hiding this comment.
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
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>
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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>
Summary
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_httpsuffix as defensive shadow.BILLING_EVENTS_HTTP_EVENT_NAME_SUFFIX— the suffix value applied to HTTP events post-cutover.toOrbEventfor the HTTP-fed path andbillingEventsS3Export.tsfor 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_ATand 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
adapters.unit.test.ts.npm run ts-build,npm run lint,npm run format:checkclean.CUTOVER_AT=2026-07-09per companion env PR): confirm dev Orb receives_http-suffixed HTTP events + unsuffixed S3 events, canonical billable metrics keep counting._s3.🤖 Generated with Claude Code