Skip to content

feat(metering): track written row count per S3 export file#6736

Merged
pfreixes merged 2 commits into
masterfrom
pau/nan-6204-s3-export-rows-metric
Jul 13, 2026
Merged

feat(metering): track written row count per S3 export file#6736
pfreixes merged 2 commits into
masterfrom
pau/nan-6204-s3-export-rows-metric

Conversation

@pfreixes

@pfreixes pfreixes commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds nango.billing.usage.clickhouse.s3_export.rows (counter, tagged by canonical metric name) — emitted after each successful S3 export, sourced from the written_rows field of ClickHouse's X-ClickHouse-Summary response header that client.command() already surfaces.
  • No SDK API change, no additional CH round-trip. The current command() call already returned the summary; we were just discarding it.

Context

Part of NAN-6204. Complements the existing s3_export.file.result counter — file count answers "did we ship?", row count answers "did we ship the right amount?".

Foundation for the sibling parity-monitoring task: reconciling our exported row counts against Orb's ingested counts (daily, per metric).

Guarantees of the metric

On the success path, the reported written_rows matches the exact number of rows in the S3 file. This holds because INSERT INTO FUNCTION s3(...) uses S3's multipart upload internally, and the target object appears atomically on CompleteMultipartUpload — either the object exists with the full payload or it doesn't exist at all. There is no observable intermediate state where the file is partially uploaded.

Failure modes and what the metric shows in each

Scenario S3 object state command() result Metric emitted?
Query succeeds, response received Full object, N rows resolves with summary.written_rows = N N counted
Query fails mid-stream (CH crash, timeout, S3 API error) No object (multipart aborted; lifecycle policy on the bucket cleans up any orphaned parts) throws ❌ not emitted — the existing .file.result{success:false} counter fires instead
CH gets partway through parts, last chunk fails before CompleteMultipartUpload No object throws ❌ not emitted
Query succeeds server-side but response lost in transit (network flake) Full object, N rows throws client-side ❌ not emitted — the S3 file is still correct; the next hourly cron tick sees objectExists() === true and skips, so the file is never re-uploaded. We lose that one row-count sample but nothing else.

The only observability gap is the last row — the "response lost in transit" case, where the file is on S3 but we don't have a row-count sample for that (day, metric). We accept this because the file itself is fine, and how often this shape shows up is already tracked by the existing .file.result{success:false} counter.

Metric shape

  • Type: counter (metrics.increment).
  • Tags: metric (canonical event name — records, proxy, function_executions, etc.).
  • Cadence: at most one sample per (day, metric) per cron day. Later firings on the same day skip via the HeadObject check.
  • Aggregation in DD: sum over a time window → total rows shipped; rate → rows/day per metric.

Test plan

  • Deploy to dev, wait for the next 00:15 UTC S3 export cron firing, verify DD receives nango.billing.usage.clickhouse.s3_export.rows samples for each canonical metric.
  • Cross-check the emitted row count against a manual SELECT count(*) from the same day/metric to confirm the summary matches ground truth.
  • Deploy to prod once dev is confirmed.

Existing integration test at billingEventsS3Export.integration.test.ts uses metricRowsSql (the SELECT portion only, no S3 target), so it can't exercise the summary path without an S3-compatible endpoint. Live validation in dev instead.

🤖 Generated with Claude Code

Adds nango.billing.usage.clickhouse.s3_export.rows (distribution, tagged
by canonical metric name). Emitted after each successful export, sourcing
`written_rows` from the ClickHouse X-ClickHouse-Summary response header
that `client.command()` already returns.

Visibility beyond the existing file-count gauge: we now know how many
rows shipped per (day, metric), which is enough to plot per-metric
volume trends in Datadog and pick up a sudden drop / spike before it
reaches Orb. Also the foundation for future daily-parity reconciliation
against Orb's ingested counts.

INSERT INTO FUNCTION s3(...) is atomic on the S3 side (multipart
completion is all-or-nothing), so on the success path the row count
matches the file's line count exactly. On the failure path the exec
throws and no metric is emitted; the only observability gap is the rare
case where CH completed the write but the response was lost in transit
— the file is still correct on S3, we just miss the row-count sample
for that tick.

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

linear-code Bot commented Jul 10, 2026

Copy link
Copy Markdown

NAN-6204

Per review. The cron produces exactly one row-count sample per (day,
metric) per day — subsequent firings that day skip via the HeadObject
check — so a distribution's percentiles don't add anything over a
counter's sum/rate, and the counter shape is cheaper in DD.

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

@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 and verified against the latest diff

Confidence score: 4/5

  • In packages/metering/lib/crons/billingEventsS3Export.ts, defaulting writtenRows to 0 when res.summary.written_rows is missing can silently turn an upstream metadata/header problem into a false “zero rows written” signal, which can mislead billing export monitoring and delay incident detection—treat missing summary data as an explicit error or emit a separate “unknown/missing summary” metric before merging.
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:201">
P2: When `res.summary` or `res.summary.written_rows` is absent, `writtenRows` silently defaults to 0 and gets emitted as a distribution metric. A missing summary header (e.g., proxy stripping it, or a ClickHouse config/version change) becomes indistinguishable from a genuinely empty export — the main observability signal this feature adds goes dark with no warning.</violation>
</file>

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

Re-trigger cubic

// exists fully or not at all), so on the success path this row
// count matches the line count in the S3 file exactly.
const res = await client.command({ query: exportSql({ metric, day, eventName, key }) });
const writtenRows = Number(res.summary?.written_rows ?? 0);

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: When res.summary or res.summary.written_rows is absent, writtenRows silently defaults to 0 and gets emitted as a distribution metric. A missing summary header (e.g., proxy stripping it, or a ClickHouse config/version change) becomes indistinguishable from a genuinely empty export — the main observability signal this feature adds goes dark with no warning.

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 201:

<comment>When `res.summary` or `res.summary.written_rows` is absent, `writtenRows` silently defaults to 0 and gets emitted as a distribution metric. A missing summary header (e.g., proxy stripping it, or a ClickHouse config/version change) becomes indistinguishable from a genuinely empty export — the main observability signal this feature adds goes dark with no warning.</comment>

<file context>
@@ -192,12 +192,21 @@ export async function exec(): Promise<void> {
+                        // exists fully or not at all), so on the success path this row
+                        // count matches the line count in the S3 file exactly.
+                        const res = await client.command({ query: exportSql({ metric, day, eventName, key }) });
+                        const writtenRows = Number(res.summary?.written_rows ?? 0);
+                        logger.info(`Exported ${eventName} for day=${day} (rows=${writtenRows})`);
                         metrics.increment(metrics.Types.BILLING_USAGE_CLICKHOUSE_S3_EXPORT_FILE_RESULT, 1, {
</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.

A 0 is what we want, we will have a monitor which will check similarity btw days

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.

A 0 is intentional here, and the separate similarity monitor covers the anomaly detection. The parent comment is too broad for this PR.

@pfreixes pfreixes requested review from a team and ErickRDev July 10, 2026 14:58
BILLING_USAGE_CLICKHOUSE_S3_EXPORT_FILE_RESULT = 'nango.billing.usage.clickhouse.s3_export.file.result',
BILLING_USAGE_CLICKHOUSE_S3_EXPORT_RUN_RESULT = 'nango.billing.usage.clickhouse.s3_export.run.result',
BILLING_USAGE_CLICKHOUSE_S3_EXPORT_DURATION_MS = 'nango.billing.usage.clickhouse.s3_export.duration_ms',
BILLING_USAGE_CLICKHOUSE_S3_EXPORT_ROWS = 'nango.billing.usage.clickhouse.s3_export.rows',

@TBonnin TBonnin Jul 13, 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.

what about some sort of reverse dns naming?

Suggested change
BILLING_USAGE_CLICKHOUSE_S3_EXPORT_ROWS = 'nango.billing.usage.clickhouse.s3_export.rows',
BILLING_USAGE_CLICKHOUSE_S3_EXPORT_ROWS = 'nango.billing.usage.clickhouse.export.s3.rows',

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.

I would prefer to keep the name that Im proposing, not because is better but because it follows the current pattern and I already have a bunch of DD panels and monitors using the s3_export. Does it sound ok to you? 🙏

@pfreixes pfreixes added this pull request to the merge queue Jul 13, 2026
Merged via the queue into master with commit 0dc5f2b Jul 13, 2026
34 checks passed
@pfreixes pfreixes deleted the pau/nan-6204-s3-export-rows-metric branch July 13, 2026 09:49
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