diff --git a/.changeset/get-delivery-requested-metrics.md b/.changeset/get-delivery-requested-metrics.md new file mode 100644 index 0000000000..f98e3123e3 --- /dev/null +++ b/.changeset/get-delivery-requested-metrics.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add `requested_metrics` to `get_media_buy_delivery`, giving the GET path the same metric narrowing the reporting webhook already has. Omitted means unchanged full payloads; impressions and spend are always included; requesting a leaf metric identity returns its canonical nested carrier; and `missing_metrics` MUST NOT flag absences caused solely by request narrowing. Implements RFC #6624. diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx index 94c7c48298..a25af222ec 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -325,7 +325,7 @@ Buyers receive the intersection of both. `impressions` and `spend` are always re The numeric values inside `viewability` and `quartile_data` are individually addressable through leaf metric identities — `viewable_rate`, `viewable_impressions`, `measurable_impressions`, `viewed_seconds`, and `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` (which resolve to `quartile_data.q1_views`–`q4_views`). Leaf identities appear in `available_metrics` declarations, `committed_metrics` contracts, `metric_aggregates` rows, and breakdown `sort_by`; the nested object remains the only carrier of the values in delivery reports. In every metric set operation (declaration, filtering, intersection, selection) a container token subsumes its leaf identities — declaring `viewability` satisfies `viewable_rate`. Note that `quartile_100` counts 100%-of-duration completions (VAST `complete`), which is distinct from `completed_views` when the buy defines a shorter billable view threshold. -Buyers can optionally request a subset via `requested_metrics` to reduce payload size and focus on relevant KPIs. +Buyers can optionally request a subset via `requested_metrics` to reduce payload size and focus on relevant KPIs. The same field exists on the [`get_media_buy_delivery`](/docs/media-buy/task-reference/get_media_buy_delivery#requesting-a-metric-subset) request with identical narrowing semantics, giving the GET path the same control the webhook already has. For `completion_rate` and `quartile_data`, sellers MAY return `null` to signal the metric does not apply (e.g. on a non-video buy), and clients MUST accept `null` as a valid value for these two fields. Every other metric signals "not applicable" by omission — sellers omit it rather than sending `null`. diff --git a/docs/media-buy/task-reference/get_media_buy_delivery.mdx b/docs/media-buy/task-reference/get_media_buy_delivery.mdx index 7c0a6b6663..73eee7e2f3 100644 --- a/docs/media-buy/task-reference/get_media_buy_delivery.mdx +++ b/docs/media-buy/task-reference/get_media_buy_delivery.mdx @@ -29,6 +29,7 @@ Retrieve comprehensive delivery metrics and performance data for media buy repor | `reporting_dimensions` | object | No | Request dimensional breakdowns within `by_package`. Include a key as an empty object (e.g., `"device_type": {}`) to activate with defaults. Keys: `geo`, `device_type`, `device_platform`, `audience`, `demographic`, `placement`, `spot`. Most accept optional `limit`, `sort_by`, and `sort_direction`. Demographic also accepts canonical `age_ranges`, each of which must match the product's independent demographic reporting capability exactly. `spot` accepts only `limit`, returns rows by `aired_at` ascending, and defaults to the complete log for the reporting period. Geo requires `geo_level`; include `system` for metro/postal levels. Unsupported dimensions are silently omitted; unsupported requested demographic ranges return [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature); malformed requests return a validation error. | | `time_granularity` | string | No | Per-window slice granularity for pull recovery, matching `reporting_webhook.reporting_frequency` vocabulary (`hourly`, `daily`, `weekly`, `monthly`, `quarterly`, `post_campaign`). When set, the response includes `windows[]` slices shape-aligned with webhook fires at the same granularity. Capability-scoped — value MUST be in the product's `reporting_capabilities.windowed_pull_granularities`. See [Windowed pull recovery](#windowed-pull-recovery). | | `include_window_breakdown` | boolean | No | When `true` (and `time_granularity` is set), include the `windows[]` array on each media buy. Defaults to `false`. Ignored when `time_granularity` is omitted. | +| `requested_metrics` | string[] | No | Narrow the response to a subset of metrics — same semantics as `reporting_webhook.requested_metrics`. Applies to `totals`, `by_package`, daily/window slices, and breakdown rows. When omitted, all available metrics are included (unchanged behavior). See [Requesting a metric subset](#requesting-a-metric-subset). | > **Date Range Behavior**: The date range is **start-inclusive, end-exclusive**. For example, `start_date: "2026-01-01"` and `end_date: "2026-01-02"` returns delivery data for January 1st only (from `2026-01-01 00:00:00` up to, but not including, `2026-01-02 00:00:00`). To get a full week of data (Jan 1-7), use `end_date: "2026-01-08"`. @@ -651,6 +652,10 @@ asyncio.run(main()) - **Attribution window**: `attribution_window` describes the lookback windows and model used for conversion attribution (e.g., 14-day click, 1-day view, last_touch) - **Package-level**: All metrics broken down by package with pacing_index +### Requesting a metric subset + +`requested_metrics` narrows the response to a specific list of metrics — the same field and semantics as `reporting_webhook.requested_metrics`, now also available on the GET path. It applies to every metrics-bearing object in the response: `totals`, `by_package`, daily and window slices, and breakdown rows. `impressions` and `spend` are always included regardless of what the list contains. Requesting a leaf metric identity (e.g. `viewable_rate`, `quartile_75`) returns its canonical nested carrier (`viewability`, `quartile_data`) rather than a flat duplicate field. A metric requested but not available for the buy is simply omitted — no error — and `missing_metrics` is unaffected by that omission: it still reconciles solely against `committed_metrics`, and sellers MUST NOT list a metric there just because the buyer excluded it via `requested_metrics`. Values outside the product's `reporting_capabilities.available_metrics` are ignored. Omitting the field preserves current behavior — all available metrics are returned. + ## Data Freshness - Reporting data typically has 2-4 hour delay diff --git a/package.json b/package.json index 62c280c970..b7d60d09b8 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "deploy:cdn-artifacts-cutover:dry-run": "wrangler deploy --config workers/artifact-cdn/wrangler.cutover.toml --dry-run", "verify:cdn-artifacts-cutover": "node scripts/verify-cdn-artifacts-cutover.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit", - "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/metric-identity-coherence.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", + "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/metric-identity-coherence.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", "test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs", "test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", diff --git a/static/schemas/source/core/reporting-webhook.json b/static/schemas/source/core/reporting-webhook.json index dfa7e0998e..24b92e4d57 100644 --- a/static/schemas/source/core/reporting-webhook.json +++ b/static/schemas/source/core/reporting-webhook.json @@ -52,7 +52,7 @@ }, "requested_metrics": { "type": "array", - "description": "Optional list of metrics to include in webhook notifications. If omitted, all available metrics are included. Must be subset of product's available_metrics. Subset evaluation and leaf resolution follow the container-subsumption rule in `enums/available-metric.json`: requesting a leaf identity selects its canonical carrier object in the payload.", + "description": "Optional list of metrics to include in webhook notifications. If omitted, all available metrics are included; an empty array has the same meaning as omission (it does not narrow to impressions and spend only). impressions and spend are always included regardless of this list. Must be subset of product's available_metrics. Subset evaluation and leaf resolution follow the container-subsumption rule in `enums/available-metric.json`: requesting a leaf identity selects its canonical carrier object in the payload. Same narrowing semantics as get_media_buy_delivery's requested_metrics (which additionally requires at least one entry when present).", "items": { "$ref": "/schemas/enums/available-metric.json" }, diff --git a/static/schemas/source/media-buy/get-media-buy-delivery-request.json b/static/schemas/source/media-buy/get-media-buy-delivery-request.json index 610f0b2520..4ac698fc7f 100644 --- a/static/schemas/source/media-buy/get-media-buy-delivery-request.json +++ b/static/schemas/source/media-buy/get-media-buy-delivery-request.json @@ -54,6 +54,13 @@ "default": false, "description": "When true, include daily_breakdown arrays within each package in by_package. Useful for per-package pacing analysis and line-item monitoring. Omit or set false to reduce response size — package daily data can be large for multi-package buys over long flights." }, + "requested_metrics": { + "type": "array", + "description": "Optional list of metrics to include in the response. When omitted, all available metrics are included (unchanged behavior). Applies to every metrics-bearing object in the response: totals, by_package, daily and window slices, and breakdown rows. impressions and spend are always included regardless of this list. Requesting a leaf metric identity returns its canonical nested carrier — e.g. requesting viewable_rate returns the viewability object, requesting quartile_75 returns quartile_data — never a flat duplicate. Metrics requested but not available for this buy are omitted from the response without error; contract accountability is unchanged — missing_metrics still reconciles against committed_metrics, but sellers MUST NOT list a metric in missing_metrics when its absence is solely due to this narrowing. Must be a subset of the product's reporting_capabilities.available_metrics; values outside the declared set are ignored. Subset evaluation follows the container-subsumption rule in enums/available-metric.json. Sort is evaluated before narrowing: excluding a metric from this list never triggers the sort_by fallback, and breakdown rows may be ordered by a metric absent from the narrowed payload — the applied-sort echo still names it. Same narrowing semantics as reporting_webhook.requested_metrics, with one shape difference: this field requires at least one entry when present (omit it entirely for full payloads), while the webhook field permits an empty array with the same meaning as omission.", + "items": { "$ref": "/schemas/enums/available-metric.json" }, + "uniqueItems": true, + "minItems": 1 + }, "time_granularity": { "$ref": "/schemas/enums/reporting-frequency.json", "description": "Per-window slice granularity for the pull, using the same vocabulary as reporting_webhook.reporting_frequency. When set, the seller returns per-window delivery slices over the date range — useful for reconstructing data a buyer's webhook receiver missed, since the slice payload is shape-aligned with what reporting_webhook would have delivered for the same window. Capability-scoped: the value MUST be one of the seller's declared reporting_capabilities.windowed_pull_granularities; otherwise the seller MUST return UNSUPPORTED_GRANULARITY. When omitted, behavior is unchanged (cumulative aggregates plus optional daily breakdowns per existing fields)." diff --git a/static/schemas/source/media-buy/get-media-buy-delivery-response.json b/static/schemas/source/media-buy/get-media-buy-delivery-response.json index d74c3a8459..90f276d0ab 100644 --- a/static/schemas/source/media-buy/get-media-buy-delivery-response.json +++ b/static/schemas/source/media-buy/get-media-buy-delivery-response.json @@ -359,7 +359,7 @@ }, "missing_metrics": { "type": "array", - "description": "Metrics that the binding reporting contract declared but that are NOT populated in this report. Reconciliation source: when `package.committed_metrics` is present, `missing_metrics` is computed against entries where `committed_at < reporting_period.end` — independent of subsequent product mutations and respecting the commitment timestamp on each entry (a metric committed mid-flight is only flagged missing in reports for periods after its commitment). When `package.committed_metrics` is absent, fall back to the product's current `reporting_capabilities.available_metrics` (no timestamp filter). Empty array (or absent) indicates clean delivery against the contract. Non-empty signals an accountability breach — the seller committed to the metric but did not produce the value here. Sellers MUST exclude metrics that are not yet measurable for the current `measurement_window` (e.g., post-IVT counts during the live window) — those will appear (or not) when a wider window supersedes this report via `supersedes_window`. Each entry uses an explicit `scope` discriminator: `standard` for entries from the closed `available-metric.json` enum, `vendor` for vendor-defined metrics anchored on a BrandRef. Symmetric with `committed_metrics`.", + "description": "Metrics that the binding reporting contract declared but that are NOT populated in this report. Reconciliation source: when `package.committed_metrics` is present, `missing_metrics` is computed against entries where `committed_at < reporting_period.end` — independent of subsequent product mutations and respecting the commitment timestamp on each entry (a metric committed mid-flight is only flagged missing in reports for periods after its commitment). When `package.committed_metrics` is absent, fall back to the product's current `reporting_capabilities.available_metrics` (no timestamp filter). Empty array (or absent) indicates clean delivery against the contract. Non-empty signals an accountability breach — the seller committed to the metric but did not produce the value here. Sellers MUST exclude metrics that are not yet measurable for the current `measurement_window` (e.g., post-IVT counts during the live window) — those will appear (or not) when a wider window supersedes this report via `supersedes_window`. Each entry uses an explicit `scope` discriminator: `standard` for entries from the closed `available-metric.json` enum, `vendor` for vendor-defined metrics anchored on a BrandRef. Symmetric with `committed_metrics`. When the request narrowed the payload via requested_metrics, sellers MUST NOT list a committed metric here solely because the buyer excluded it — missing_metrics reports delivery gaps, not request narrowing.", "items": { "$ref": "/schemas/core/missing-metric.json" }, diff --git a/tests/mcp-schema-analysis.test.cjs b/tests/mcp-schema-analysis.test.cjs index 6bedcc11ef..8824b1c60f 100644 --- a/tests/mcp-schema-analysis.test.cjs +++ b/tests/mcp-schema-analysis.test.cjs @@ -34,7 +34,7 @@ test("input-field weight report attributes the largest transitive schema graphs" const report = analyzeInputSchemaWeights(schemas); assert.equal(report.tool_count, 16); - assert.equal(report.definition_instances, 582); + assert.equal(report.definition_instances, 583); assert.equal(report.unique_definitions, 152); assert.equal(report.repeated_definitions, 109); assert.ok(report.repeated_definition_bytes > 180_000); diff --git a/tests/requested-metrics-contract.test.cjs b/tests/requested-metrics-contract.test.cjs new file mode 100644 index 0000000000..cabb7585a1 --- /dev/null +++ b/tests/requested-metrics-contract.test.cjs @@ -0,0 +1,108 @@ +const fs = require("fs"); +const path = require("path"); +const Ajv = require("ajv"); +const addFormats = require("ajv-formats"); +const { describe, it, before } = require("node:test"); +const assert = require("node:assert/strict"); + +const SCHEMA_ROOT = path.join(__dirname, "..", "static", "schemas", "source"); + +function readSchema(uri) { + assert.match(uri, /^\/schemas\//); + return JSON.parse( + fs.readFileSync(path.join(SCHEMA_ROOT, uri.slice("/schemas/".length)), "utf8") + ); +} + +async function compile(schema) { + const ajv = new Ajv({ + allErrors: true, + strict: false, + loadSchema: async (ref) => readSchema(ref), + }); + addFormats(ajv); + return ajv.compileAsync(schema); +} + +describe("requested_metrics contract (get_media_buy_delivery)", () => { + let validateRequest; + let requestJson; + let responseJson; + let webhookJson; + + before(async () => { + requestJson = readSchema( + "/schemas/media-buy/get-media-buy-delivery-request.json" + ); + responseJson = readSchema( + "/schemas/media-buy/get-media-buy-delivery-response.json" + ); + webhookJson = readSchema("/schemas/core/reporting-webhook.json"); + + validateRequest = await compile(requestJson); + }); + + it("accepts a valid requested_metrics list of standard metrics", () => { + assert.equal( + validateRequest({ + requested_metrics: ["impressions", "spend", "quartile_100"], + }), + true, + JSON.stringify(validateRequest.errors) + ); + }); + + it("accepts a requested_metrics list containing a leaf metric identity", () => { + assert.equal( + validateRequest({ requested_metrics: ["viewable_rate"] }), + true, + JSON.stringify(validateRequest.errors) + ); + }); + + it("rejects an empty requested_metrics array (minItems)", () => { + assert.equal(validateRequest({ requested_metrics: [] }), false); + }); + + it("rejects a requested_metrics value outside the available-metric enum", () => { + assert.equal( + validateRequest({ requested_metrics: ["bogus_metric"] }), + false + ); + }); + + it("rejects duplicate entries in requested_metrics (uniqueItems)", () => { + assert.equal( + validateRequest({ requested_metrics: ["clicks", "clicks"] }), + false + ); + }); + + it("request field description states impressions and spend are always included", () => { + assert.match( + requestJson.properties.requested_metrics.description, + /impressions and spend are always included/ + ); + }); + + it("response missing_metrics description states requested_metrics narrowing MUST NOT be flagged", () => { + const missingMetricsDescription = + responseJson.properties.media_buy_deliveries.items.properties.by_package + .items.allOf[1].properties.missing_metrics.description; + + assert.match( + missingMetricsDescription, + /MUST NOT list a committed metric here solely because the buyer excluded it/ + ); + }); + + it("reporting_webhook still declares requested_metrics with available-metric items (parity guard)", () => { + const webhookField = webhookJson.properties.requested_metrics; + assert.ok(webhookField, "reporting-webhook.json is missing requested_metrics"); + assert.equal(webhookField.type, "array"); + assert.equal( + webhookField.items.$ref, + "/schemas/enums/available-metric.json" + ); + }); +});