Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/billing/lib/clients/orb/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@ import { uuidv7 } from 'uuidv7';

import { Err, Ok } from '@nangohq/utils';

import { envs } from '../../envs.js';
import { putOrbCustomerSchema } from './types.js';

import type { BillingAddress, BillingCustomer, BillingEvent, BillingInvoicingDetails, Result, UsageMetric } from '@nangohq/types';
import type Orb from 'orb-billing';

// Keyed on the EVENT's timestamp, not the wall clock, so a batched or
// late-emitted event whose logical time is pre-cutover ships under the
// pre-cutover name and vice versa. See BILLING_EVENTS_CUTOVER_AT in
// packages/utils.
function cutoverAppliesTo(eventTimestamp: Date): boolean {
return !!envs.BILLING_EVENTS_CUTOVER_AT && eventTimestamp >= new Date(envs.BILLING_EVENTS_CUTOVER_AT);
}

export function toOrbEvent(event: BillingEvent): Orb.Events.EventIngestParams.Event {
const { idempotencyKey, timestamp, accountId, ...rest } = event.properties;

Expand All @@ -24,7 +33,7 @@ export function toOrbEvent(event: BillingEvent): Orb.Events.EventIngestParams.Ev
}

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

event_name: `${event.type}${cutoverAppliesTo(timestamp) ? '_http' : ''}`,
idempotency_key: idempotencyKey || uuidv7(),
external_customer_id: accountId.toString(),
timestamp: timestamp.toISOString(),
Expand Down
44 changes: 44 additions & 0 deletions packages/billing/lib/clients/orb/adapters.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';

import { envs } from '../../envs.js';
import { fromOrbAddress, fromOrbCustomer, orbMetricToUsageMetric, toOrbEvent, toOrbPutCustomerPayload } from './adapters.js';

import type { BillingEvent, BillingInvoicingDetails } from '@nangohq/types';
Expand Down Expand Up @@ -66,6 +67,49 @@ describe('toOrbEvent', () => {
expect(result.external_customer_id).toBe('42');
expect(result.timestamp).toBe('2024-01-15T10:00:00.000Z');
});

it('appends "_http" when the event timestamp is at or after the cutover', () => {
const originalCutover = envs.BILLING_EVENTS_CUTOVER_AT;
try {
(envs as any).BILLING_EVENTS_CUTOVER_AT = '2000-01-01T00:00:00Z';
const event: BillingEvent = { type: 'proxy', properties: { ...baseProperties } as any };
expect(toOrbEvent(event).event_name).toBe('proxy_http');
} finally {
(envs as any).BILLING_EVENTS_CUTOVER_AT = originalCutover;
}
});

it('does not append "_http" when the event timestamp is before the cutover', () => {
const originalCutover = envs.BILLING_EVENTS_CUTOVER_AT;
try {
(envs as any).BILLING_EVENTS_CUTOVER_AT = '9999-01-01T00:00:00Z';
const event: BillingEvent = { type: 'proxy', properties: { ...baseProperties } as any };
expect(toOrbEvent(event).event_name).toBe('proxy');
} finally {
(envs as any).BILLING_EVENTS_CUTOVER_AT = originalCutover;
}
});

it('keys the suffix on each event timestamp independently — a batched pre-cutover event stays unsuffixed even when processed after cutover', () => {
// Same cutover instant, two events on either side of it: verifies the
// suffix is decided per-event, not from wall-clock at processing time.
const originalCutover = envs.BILLING_EVENTS_CUTOVER_AT;
try {
(envs as any).BILLING_EVENTS_CUTOVER_AT = '2024-06-01T00:00:00Z';
const beforeCutover: BillingEvent = {
type: 'proxy',
properties: { ...baseProperties, timestamp: new Date('2024-05-31T23:59:59.999Z') } as any
};
const atCutover: BillingEvent = {
type: 'proxy',
properties: { ...baseProperties, timestamp: new Date('2024-06-01T00:00:00.000Z') } as any
};
expect(toOrbEvent(beforeCutover).event_name).toBe('proxy');
expect(toOrbEvent(atCutover).event_name).toBe('proxy_http');
} finally {
(envs as any).BILLING_EVENTS_CUTOVER_AT = originalCutover;
}
});
});

// ─── toOrbPutCustomerPayload ──────────────────────────────────────────────────
Expand Down
19 changes: 13 additions & 6 deletions packages/metering/lib/crons/billingEventsS3Export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ const cronMinute = envs.CRON_BILLING_EVENTS_S3_HOURLY_EXPORT_MINUTE;
const bucket = envs.BILLING_EVENTS_S3_BUCKET;
const roleArn = envs.BILLING_EVENTS_S3_WRITER_ROLE_ARN;
const region = envs.BILLING_EVENTS_S3_REGION;
const eventNameSuffix = envs.BILLING_EVENTS_S3_EVENT_NAME_SUFFIX ?? '';

// Keyed on the DATA DAY, not the wall clock. Matters at the seam: the
// Aug 1 00:15 UTC firing exports July 31 data, and July 31 is still
// pre-cutover — so those rows must ship as "_s3" shadow, not canonical.
// 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>

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.

Ack — this is a real edge case if CUTOVER_AT is ever set to a non-midnight instant, and in that case using day-end (as suggested) would prevent losing a day of customer billing on the
straddling day.

Example: if CUTOVER_AT = 2026-08-01T14:00:00Z, day 2026-08-01 has day-start = 2026-08-01T00:00:00Z < 14:00Z → current check says "pre-cutover" → file ships as records_s3. Customer plans
filter on canonical records, so Aug 1 goes missing from their invoice. Day-end (23:59:59Z >= 14:00Z) would flip it to canonical and keep the day billed correctly.

For our config (CUTOVER_AT = 2026-08-01T00:00:00Z exactly midnight, both dev and prod) day-start and day-end give the same answer for every day, so this doesn't manifest. The whole
time-based cutover mechanism is scheduled to be removed next month once we're settled on the S3-fed pipeline, so I'd rather not add a hardening branch for a scenario that only arises if
someone actively picks a non-midnight timestamp. Leaving as-is.

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.

The edge case is real for non-midnight cutovers, but it doesn’t apply here because CUTOVER_AT is midnight in both dev and prod. Since this cutover mechanism is being removed next month, the parent comment is too broad for this PR.

}

const LOCK_KEY = 'lock:cron:billingEventsS3Export';
// Cron fires hourly; lock should expire well before the next tick.
Expand Down Expand Up @@ -214,13 +222,12 @@ export function billingEventsS3ExportCron(): void {
});
}

function addEventNameSuffix(eventName: MetricSpec['canonicalEventName']): string {
if (eventName == 'data_transfer') {
function addEventNameSuffix(eventName: MetricSpec['canonicalEventName'], day: string): string {
if (eventName === 'data_transfer') {
// data_transfer is a new event and thus doesn't need the suffix to support live traffic cutoff.
return eventName;
}

return `${eventName}${eventNameSuffix}`;
return `${eventName}${dayIsPostCutover(day) ? '' : '_s3'}`;
}

export async function exec(): Promise<void> {
Expand All @@ -236,7 +243,7 @@ export async function exec(): Promise<void> {
let anyFailure = false;
try {
for (const metric of METRICS) {
const eventName = addEventNameSuffix(metric.canonicalEventName);
const eventName = addEventNameSuffix(metric.canonicalEventName, day);
const key = objectKey({ day, eventName });
const start = process.hrtime.bigint();
// Tracks which step is in flight so a catch can tag the failure
Expand Down
9 changes: 8 additions & 1 deletion packages/utils/lib/environment/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,14 @@ export const ENVS = z.object({
BILLING_INGEST_MAX_RETRY: z.coerce.number().optional().default(3),
BILLING_EVENTS_S3_BUCKET: z.string().optional(),
BILLING_EVENTS_S3_WRITER_ROLE_ARN: z.string().optional(),
BILLING_EVENTS_S3_EVENT_NAME_SUFFIX: z.string().optional(),
// Temporary. ISO 8601 timestamp at which the S3-fed pipeline becomes
// authoritative for billing. Before this instant, S3 events ship as
// "<name>_s3" shadow and HTTP events ship canonical (unsuffixed). At
// and after this instant, the two swap roles — HTTP events pick up
// the "_http" suffix and S3 events become canonical. 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(),

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.

We have this config which if Im not mistaken is a correct value - name: BILLING_EVENTS_CUTOVER_AT value: "2026-08-01T00:00:00Z"

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.

That value looks valid as a Z timestamp. The parent comment was about explicit-offset ISO 8601 values, so it still applies to that case.

BILLING_EVENTS_S3_REGION: z.string().optional().default('us-west-2'),
// DLQ bucket Orb writes to when it can't ingest a billing event. Watched by the
// metering DLQ monitor cron (CRON_BILLING_EVENTS_S3_DLQ_MONITOR_MINUTE).
Expand Down
Loading