@shware/analytics 11.9.0 → 11.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -344,7 +344,8 @@ What the SDK guarantees for this to hold:
344
344
  ## Ad performance (`@shware/analytics/ads`)
345
345
 
346
346
  Spend, impressions, clicks and the platforms' own conversions, read from the ad platforms'
347
- reporting APIs as typed `AdPerformanceRow`s — one per ad and hour — for a host to store next to its
347
+ reporting APIs as typed `AdPerformanceRow`s — one per ad (or the finest level a platform reports
348
+ hours at) and hour — for a host to store next to its
348
349
  sessions and compute ROAS, cost per session and the click → session landing rate.
349
350
 
350
351
  ```ts
@@ -386,6 +387,131 @@ const rows = await fetchMetaAdPerformance({
386
387
  day before the first hourly day stored — a day must never hold both spread and real hourly rows,
387
388
  as an hour without delivery is absent from the hourly data and its spread share would remain.
388
389
 
390
+ ### Google Ads, pushed by a script
391
+
392
+ Google's reporting API needs a developer token, which needs a manager account. A
393
+ [Google Ads script](https://developers.google.com/google-ads/scripts) needs neither: it runs inside
394
+ the ad account (Tools → Bulk actions → Scripts) as the account's own user, reads the report and
395
+ posts it to the host, which turns the body into rows with `parseGoogleAdsScriptReport` and upserts
396
+ them.
397
+
398
+ ```ts
399
+ import { GoogleAdsReportError, parseGoogleAdsScriptReport } from '@shware/analytics/ads';
400
+
401
+ // POST, behind a bearer key only the script knows
402
+ try {
403
+ await upsert(parseGoogleAdsScriptReport(await request.json()));
404
+ } catch (error) {
405
+ if (error instanceof GoogleAdsReportError) return new Response(error.message, { status: 400 });
406
+ throw error;
407
+ }
408
+ ```
409
+
410
+ - **Ad groups, not ads.** Google reports hours down to the ad group (`segments.hour` is not
411
+ available per ad), and a Performance Max campaign has no ad group: `ad_id` is the ad group id, or
412
+ the campaign id for Performance Max. The script reads both, and logs a warning when they do not
413
+ add up to the account's spend.
414
+ - **Conversions** are Google's primary conversion actions, click-based (view-through ones are
415
+ counted apart and have no value), so they fill the total and the click-through columns alike.
416
+ - **Restatement.** A conversion is credited to its click's hour up to the conversion window later
417
+ (30 days by default). The script re-reads the last 3 days every hour and the last 30 once a day.
418
+ - **History.** Set `SINCE` to the first day with spend, run the script once by hand, then clear
419
+ it; it posts 7 days per request. Hourly data reaches back at least months — compare each day's
420
+ sum of hours with the day in the Google Ads UI before relying on older days.
421
+
422
+ Schedule it hourly. Google Ads emails the account owner when a run throws, as on a non-2xx answer.
423
+
424
+ ```js
425
+ const ENDPOINT = 'https://api.example.com/ads/google';
426
+ const KEY = '…'; // the bearer key the endpoint checks
427
+ const DAYS = 3; // re-read every hour
428
+ const RESTATE_DAYS = 30; // re-read once a day, at RESTATE_HOUR in the account's time zone
429
+ const RESTATE_HOUR = 5;
430
+ const CHUNK_DAYS = 7; // days per request
431
+ const SINCE = ''; // a backfill's first day, 'YYYY-MM-DD'; empty for the schedule
432
+
433
+ function main() {
434
+ const account = AdsApp.currentAccount();
435
+ const timeZone = account.getTimeZone();
436
+ const now = new Date();
437
+ const today = Utilities.formatDate(now, timeZone, 'yyyy-MM-dd');
438
+ const hour = Number(Utilities.formatDate(now, timeZone, 'H'));
439
+ const since = SINCE || addDays(today, 1 - (hour === RESTATE_HOUR ? RESTATE_DAYS : DAYS));
440
+
441
+ for (let from = since; from <= today; from = addDays(from, CHUNK_DAYS)) {
442
+ const last = addDays(from, CHUNK_DAYS - 1);
443
+ const until = last < today ? last : today;
444
+ const range = `segments.date BETWEEN '${from}' AND '${until}'`;
445
+ const metrics = `segments.date, segments.hour, metrics.cost_micros, metrics.impressions,
446
+ metrics.clicks, metrics.conversions, metrics.conversions_value`;
447
+ const rows = [
448
+ ...read(`SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ${metrics}
449
+ FROM ad_group WHERE ${range} AND metrics.impressions > 0`),
450
+ ...read(`SELECT campaign.id, campaign.name, ${metrics} FROM campaign
451
+ WHERE ${range} AND metrics.impressions > 0
452
+ AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'`),
453
+ ];
454
+ check(rows, range, from, until);
455
+ post({
456
+ account_id: account.getCustomerId(),
457
+ currency: account.getCurrencyCode(),
458
+ time_zone: timeZone,
459
+ rows,
460
+ });
461
+ Logger.log('%s → %s: %s rows', from, until, rows.length);
462
+ }
463
+ }
464
+
465
+ function read(query) {
466
+ const rows = [];
467
+ const results = AdsApp.search(query);
468
+ while (results.hasNext()) {
469
+ const r = results.next();
470
+ rows.push({
471
+ campaign_id: r.campaign.id,
472
+ campaign_name: r.campaign.name,
473
+ ad_group_id: r.adGroup ? r.adGroup.id : null,
474
+ ad_group_name: r.adGroup ? r.adGroup.name : null,
475
+ date: r.segments.date,
476
+ hour: r.segments.hour,
477
+ cost_micros: r.metrics.costMicros,
478
+ impressions: r.metrics.impressions,
479
+ clicks: r.metrics.clicks,
480
+ conversions: r.metrics.conversions,
481
+ conversion_value: r.metrics.conversionsValue,
482
+ });
483
+ }
484
+ return rows;
485
+ }
486
+
487
+ /** Warns when the rows miss spend: a campaign type with neither ad groups nor Performance Max. */
488
+ function check(rows, range, from, until) {
489
+ let total = 0;
490
+ const results = AdsApp.search(`SELECT metrics.cost_micros FROM customer WHERE ${range}`);
491
+ while (results.hasNext()) total += Number(results.next().metrics.costMicros);
492
+ const got = rows.reduce((sum, row) => sum + Number(row.cost_micros), 0);
493
+ if (got !== total) Logger.log('WARNING %s → %s: read %s of %s micros', from, until, got, total);
494
+ }
495
+
496
+ function post(report) {
497
+ const response = UrlFetchApp.fetch(ENDPOINT, {
498
+ method: 'post',
499
+ contentType: 'application/json',
500
+ headers: { Authorization: `Bearer ${KEY}` },
501
+ payload: JSON.stringify(report),
502
+ muteHttpExceptions: true,
503
+ });
504
+ const code = response.getResponseCode();
505
+ if (code < 200 || code >= 300) throw new Error(`${code}: ${response.getContentText()}`);
506
+ }
507
+
508
+ function addDays(date, days) {
509
+ const moved = new Date(`${date}T00:00:00Z`);
510
+ moved.setUTCDate(moved.getUTCDate() + days);
511
+ return moved.toISOString().slice(0, 10);
512
+ }
513
+ ```
514
+
389
515
  ## UTM params
390
516
 
391
517
  Typed as `UTMParams` (exported from `@shware/analytics`). Value unions follow the
@@ -0,0 +1,128 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_attribution_classify = require("../attribution/classify.cjs");
3
+ require("../attribution/index.cjs");
4
+ const require_ads_time = require("./time.cjs");
5
+ let zod_mini = require("zod/mini");
6
+ //#region src/ads/google.ts
7
+ /**
8
+ * Google Ads delivery pushed by a Google Ads script: the script runs inside the ad account on
9
+ * Google's schedule, reads the hourly report with `AdsApp.search` and posts it to the host, which
10
+ * turns it into `AdPerformanceRow`s here. No developer token, manager account or OAuth client is
11
+ * needed — the script runs as the account's own user. The script is in the README.
12
+ *
13
+ * Google reports hours down to the ad group, not the ad (`segments.hour` is incompatible with
14
+ * `ad_group_ad`), and Performance Max campaigns have no ad groups at all; so a row is an ad group's
15
+ * hour, or a Performance Max campaign's, and `ad_id` holds that ad group or campaign id.
16
+ *
17
+ * https://developers.google.com/google-ads/scripts/docs/features/reports
18
+ * https://developers.google.com/google-ads/api/fields/v21/ad_group
19
+ */
20
+ /** What a Google ad click is in the attribution vocabulary: a `gclid` is `google / cpc`. */
21
+ const CHANNEL = "google";
22
+ const MEDIUM = "cpc";
23
+ /** `AdsApp.search` returns int64 metrics as strings and doubles as numbers; either is taken. */
24
+ const metric = (0, zod_mini.optional)((0, zod_mini.union)([(0, zod_mini.string)(), (0, zod_mini.number)()]));
25
+ const reportRowSchema = (0, zod_mini.object)({
26
+ campaign_id: (0, zod_mini.string)(),
27
+ campaign_name: (0, zod_mini.optional)((0, zod_mini.nullable)((0, zod_mini.string)())),
28
+ /** Null for a Performance Max campaign's row. */
29
+ ad_group_id: (0, zod_mini.optional)((0, zod_mini.nullable)((0, zod_mini.string)())),
30
+ ad_group_name: (0, zod_mini.optional)((0, zod_mini.nullable)((0, zod_mini.string)())),
31
+ /** `segments.date`, a day in the account's time zone. */
32
+ date: (0, zod_mini.string)(),
33
+ /** `segments.hour`, 0–23, of that day. */
34
+ hour: (0, zod_mini.number)(),
35
+ cost_micros: metric,
36
+ impressions: metric,
37
+ clicks: metric,
38
+ conversions: metric,
39
+ conversion_value: metric
40
+ });
41
+ const reportSchema = (0, zod_mini.object)({
42
+ /** The customer id, digits only or as `123-456-7890`. */
43
+ account_id: (0, zod_mini.string)(),
44
+ currency: (0, zod_mini.string)(),
45
+ /** `AdsApp.currentAccount().getTimeZone()`, the IANA zone `date` and `hour` are in. */
46
+ time_zone: (0, zod_mini.string)(),
47
+ rows: (0, zod_mini.array)(reportRowSchema)
48
+ });
49
+ /** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */
50
+ var GoogleAdsReportError = class extends Error {
51
+ constructor(message) {
52
+ super(message);
53
+ this.name = "GoogleAdsReportError";
54
+ }
55
+ };
56
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
57
+ /** A metric as a number, absent as `fallback`; anything not numeric is an invalid report. */
58
+ function numeric(value, fallback, path) {
59
+ if (value === void 0) return fallback;
60
+ const n = Number(value);
61
+ if (!Number.isFinite(n)) throw new GoogleAdsReportError(`${path}: not a number: ${value}`);
62
+ return n;
63
+ }
64
+ function timeZoneOf(timeZone) {
65
+ try {
66
+ new Intl.DateTimeFormat("en-US", { timeZone });
67
+ } catch {
68
+ throw new GoogleAdsReportError(`Unknown time zone: ${timeZone}`);
69
+ }
70
+ return timeZone;
71
+ }
72
+ /**
73
+ * The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max
74
+ * campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from
75
+ * `cost_micros`. Google's `conversions` are its primary conversion actions under their own
76
+ * attribution model, click-based (view-through conversions are counted apart, without value),
77
+ * so they fill both the total and the click-through columns.
78
+ *
79
+ * Google restates the past — a conversion is credited to the hour of its click, up to the
80
+ * conversion window (30 days by default) later — so the script re-reads a trailing window and the
81
+ * host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).
82
+ *
83
+ * @throws GoogleAdsReportError when the body is not a report.
84
+ */
85
+ function parseGoogleAdsScriptReport(body) {
86
+ const parsed = (0, zod_mini.safeParse)(reportSchema, body);
87
+ if (!parsed.success) {
88
+ const issues = parsed.error.issues;
89
+ const first = issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
90
+ throw new GoogleAdsReportError(`Invalid Google Ads report (${issues.length} issues): ${first}${issues.length > 3 ? "; …" : ""}`);
91
+ }
92
+ const report = parsed.data;
93
+ const accountId = report.account_id.replaceAll("-", "");
94
+ const timeZone = timeZoneOf(report.time_zone);
95
+ return report.rows.map((row, index) => {
96
+ const path = `rows.${index}`;
97
+ if (!DATE.test(row.date) || !Number.isInteger(row.hour) || row.hour < 0 || row.hour > 23) throw new GoogleAdsReportError(`${path}: unexpected hour ${row.date} ${row.hour}`);
98
+ const conversions = numeric(row.conversions, null, `${path}.conversions`);
99
+ const value = numeric(row.conversion_value, null, `${path}.conversion_value`);
100
+ return {
101
+ platform: "google",
102
+ account_id: accountId,
103
+ campaign_id: row.campaign_id,
104
+ campaign_name: row.campaign_name ?? null,
105
+ ad_group_id: row.ad_group_id ?? null,
106
+ ad_group_name: row.ad_group_name ?? null,
107
+ ad_id: row.ad_group_id ?? row.campaign_id,
108
+ ad_name: null,
109
+ channel: CHANNEL,
110
+ medium: MEDIUM,
111
+ channel_group: require_attribution_classify.channelGroupOf(CHANNEL, MEDIUM),
112
+ hour_start: require_ads_time.zonedHourToUtc(row.date, row.hour, timeZone).toISOString(),
113
+ currency: report.currency,
114
+ spend: numeric(row.cost_micros, 0, `${path}.cost_micros`) / 1e6,
115
+ impressions: numeric(row.impressions, 0, `${path}.impressions`),
116
+ clicks: numeric(row.clicks, 0, `${path}.clicks`),
117
+ conversions,
118
+ conversion_value: value,
119
+ click_conversions: conversions,
120
+ click_conversion_value: value
121
+ };
122
+ });
123
+ }
124
+ //#endregion
125
+ exports.GoogleAdsReportError = GoogleAdsReportError;
126
+ exports.parseGoogleAdsScriptReport = parseGoogleAdsScriptReport;
127
+
128
+ //# sourceMappingURL=google.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google.cjs","names":["optional","union","string","number","object","nullable","array","safeParse","channelGroupOf","zonedHourToUtc"],"sources":["../../src/ads/google.ts"],"sourcesContent":["/**\n * Google Ads delivery pushed by a Google Ads script: the script runs inside the ad account on\n * Google's schedule, reads the hourly report with `AdsApp.search` and posts it to the host, which\n * turns it into `AdPerformanceRow`s here. No developer token, manager account or OAuth client is\n * needed — the script runs as the account's own user. The script is in the README.\n *\n * Google reports hours down to the ad group, not the ad (`segments.hour` is incompatible with\n * `ad_group_ad`), and Performance Max campaigns have no ad groups at all; so a row is an ad group's\n * hour, or a Performance Max campaign's, and `ad_id` holds that ad group or campaign id.\n *\n * https://developers.google.com/google-ads/scripts/docs/features/reports\n * https://developers.google.com/google-ads/api/fields/v21/ad_group\n */\nimport {\n type infer as Infer,\n array,\n nullable,\n number,\n object,\n optional,\n safeParse,\n string,\n union,\n} from 'zod/mini';\nimport { channelGroupOf } from '../attribution';\nimport { zonedHourToUtc } from './time';\nimport type { AdPerformanceRow } from './types';\n\n/** What a Google ad click is in the attribution vocabulary: a `gclid` is `google / cpc`. */\nconst CHANNEL = 'google';\nconst MEDIUM = 'cpc';\n\n/** `AdsApp.search` returns int64 metrics as strings and doubles as numbers; either is taken. */\nconst metric = optional(union([string(), number()]));\n\nconst reportRowSchema = object({\n campaign_id: string(),\n campaign_name: optional(nullable(string())),\n /** Null for a Performance Max campaign's row. */\n ad_group_id: optional(nullable(string())),\n ad_group_name: optional(nullable(string())),\n /** `segments.date`, a day in the account's time zone. */\n date: string(),\n /** `segments.hour`, 0–23, of that day. */\n hour: number(),\n cost_micros: metric,\n impressions: metric,\n clicks: metric,\n conversions: metric,\n conversion_value: metric,\n});\n\nconst reportSchema = object({\n /** The customer id, digits only or as `123-456-7890`. */\n account_id: string(),\n currency: string(),\n /** `AdsApp.currentAccount().getTimeZone()`, the IANA zone `date` and `hour` are in. */\n time_zone: string(),\n rows: array(reportRowSchema),\n});\n\n/** The body the Google Ads script posts: the account and its report rows, as read. */\nexport type GoogleAdsScriptReport = Infer<typeof reportSchema>;\n\n/** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */\nexport class GoogleAdsReportError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GoogleAdsReportError';\n }\n}\n\nconst DATE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** A metric as a number, absent as `fallback`; anything not numeric is an invalid report. */\nfunction numeric<T extends number | null>(\n value: string | number | undefined,\n fallback: T,\n path: string\n): number | T {\n if (value === undefined) return fallback;\n const n = Number(value);\n if (!Number.isFinite(n)) throw new GoogleAdsReportError(`${path}: not a number: ${value}`);\n return n;\n}\n\nfunction timeZoneOf(timeZone: string): string {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone });\n } catch {\n throw new GoogleAdsReportError(`Unknown time zone: ${timeZone}`);\n }\n return timeZone;\n}\n\n/**\n * The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max\n * campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from\n * `cost_micros`. Google's `conversions` are its primary conversion actions under their own\n * attribution model, click-based (view-through conversions are counted apart, without value),\n * so they fill both the total and the click-through columns.\n *\n * Google restates the past — a conversion is credited to the hour of its click, up to the\n * conversion window (30 days by default) later — so the script re-reads a trailing window and the\n * host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).\n *\n * @throws GoogleAdsReportError when the body is not a report.\n */\nexport function parseGoogleAdsScriptReport(body: unknown): AdPerformanceRow[] {\n const parsed = safeParse(reportSchema, body);\n if (!parsed.success) {\n const issues = parsed.error.issues;\n const first = issues\n .slice(0, 3)\n .map((issue) => `${issue.path.join('.')}: ${issue.message}`)\n .join('; ');\n throw new GoogleAdsReportError(\n `Invalid Google Ads report (${issues.length} issues): ${first}${issues.length > 3 ? '; …' : ''}`\n );\n }\n\n const report = parsed.data;\n const accountId = report.account_id.replaceAll('-', '');\n const timeZone = timeZoneOf(report.time_zone);\n return report.rows.map((row, index) => {\n const path = `rows.${index}`;\n if (!DATE.test(row.date) || !Number.isInteger(row.hour) || row.hour < 0 || row.hour > 23) {\n throw new GoogleAdsReportError(`${path}: unexpected hour ${row.date} ${row.hour}`);\n }\n const conversions = numeric(row.conversions, null, `${path}.conversions`);\n const value = numeric(row.conversion_value, null, `${path}.conversion_value`);\n return {\n platform: 'google',\n account_id: accountId,\n campaign_id: row.campaign_id,\n campaign_name: row.campaign_name ?? null,\n ad_group_id: row.ad_group_id ?? null,\n ad_group_name: row.ad_group_name ?? null,\n ad_id: row.ad_group_id ?? row.campaign_id,\n ad_name: null,\n channel: CHANNEL,\n medium: MEDIUM,\n channel_group: channelGroupOf(CHANNEL, MEDIUM),\n hour_start: zonedHourToUtc(row.date, row.hour, timeZone).toISOString(),\n currency: report.currency,\n spend: numeric(row.cost_micros, 0, `${path}.cost_micros`) / 1e6,\n impressions: numeric(row.impressions, 0, `${path}.impressions`),\n clicks: numeric(row.clicks, 0, `${path}.clicks`),\n conversions,\n conversion_value: value,\n click_conversions: conversions,\n click_conversion_value: value,\n };\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,43 @@
1
+ import { AdPerformanceRow } from "./types.cjs";
2
+ import { infer as __Infer } from "zod/mini";
3
+ //#region src/ads/google.d.ts
4
+ declare const reportSchema: import("zod/mini").ZodMiniObject<{
5
+ account_id: import("zod/mini").ZodMiniString<string>;
6
+ currency: import("zod/mini").ZodMiniString<string>;
7
+ time_zone: import("zod/mini").ZodMiniString<string>;
8
+ rows: import("zod/mini").ZodMiniArray<import("zod/mini").ZodMiniObject<{
9
+ campaign_id: import("zod/mini").ZodMiniString<string>;
10
+ campaign_name: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
11
+ ad_group_id: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
12
+ ad_group_name: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
13
+ date: import("zod/mini").ZodMiniString<string>;
14
+ hour: import("zod/mini").ZodMiniNumber<number>;
15
+ cost_micros: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
16
+ impressions: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
17
+ clicks: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
18
+ conversions: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
19
+ conversion_value: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
20
+ }, import("zod/v4/core").$strip>>;
21
+ }, import("zod/v4/core").$strip>;
22
+ /** The body the Google Ads script posts: the account and its report rows, as read. */
23
+ export type GoogleAdsScriptReport = __Infer<typeof reportSchema>;
24
+ /** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */
25
+ export declare class GoogleAdsReportError extends Error {
26
+ constructor(message: string);
27
+ }
28
+ /**
29
+ * The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max
30
+ * campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from
31
+ * `cost_micros`. Google's `conversions` are its primary conversion actions under their own
32
+ * attribution model, click-based (view-through conversions are counted apart, without value),
33
+ * so they fill both the total and the click-through columns.
34
+ *
35
+ * Google restates the past — a conversion is credited to the hour of its click, up to the
36
+ * conversion window (30 days by default) later — so the script re-reads a trailing window and the
37
+ * host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).
38
+ *
39
+ * @throws GoogleAdsReportError when the body is not a report.
40
+ */
41
+ export declare function parseGoogleAdsScriptReport(body: unknown): AdPerformanceRow[];
42
+ //#endregion
43
+ //# sourceMappingURL=google.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google.d.cts","names":["Infer"],"sources":["../../src/ads/google.ts"],"mappings":";;;cAoDM,iCAAY;;;;;;;;;;;;;;;;;yBAOhB;;YAGU,wBAAwBA,eAAa;;qBAGpC,6BAA6B;EACxC,YAAY;;;;;;;;;;;;;;;wBA0CE,2BAA2B,gBAAgB"}
@@ -0,0 +1,43 @@
1
+ import { AdPerformanceRow } from "./types.mjs";
2
+ import { infer as __Infer } from "zod/mini";
3
+ //#region src/ads/google.d.ts
4
+ declare const reportSchema: import("zod/mini").ZodMiniObject<{
5
+ account_id: import("zod/mini").ZodMiniString<string>;
6
+ currency: import("zod/mini").ZodMiniString<string>;
7
+ time_zone: import("zod/mini").ZodMiniString<string>;
8
+ rows: import("zod/mini").ZodMiniArray<import("zod/mini").ZodMiniObject<{
9
+ campaign_id: import("zod/mini").ZodMiniString<string>;
10
+ campaign_name: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
11
+ ad_group_id: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
12
+ ad_group_name: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniNullable<import("zod/mini").ZodMiniString<string>>>;
13
+ date: import("zod/mini").ZodMiniString<string>;
14
+ hour: import("zod/mini").ZodMiniNumber<number>;
15
+ cost_micros: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
16
+ impressions: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
17
+ clicks: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
18
+ conversions: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
19
+ conversion_value: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniUnion<readonly [import("zod/mini").ZodMiniString<string>, import("zod/mini").ZodMiniNumber<number>]>>;
20
+ }, import("zod/v4/core").$strip>>;
21
+ }, import("zod/v4/core").$strip>;
22
+ /** The body the Google Ads script posts: the account and its report rows, as read. */
23
+ export type GoogleAdsScriptReport = __Infer<typeof reportSchema>;
24
+ /** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */
25
+ export declare class GoogleAdsReportError extends Error {
26
+ constructor(message: string);
27
+ }
28
+ /**
29
+ * The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max
30
+ * campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from
31
+ * `cost_micros`. Google's `conversions` are its primary conversion actions under their own
32
+ * attribution model, click-based (view-through conversions are counted apart, without value),
33
+ * so they fill both the total and the click-through columns.
34
+ *
35
+ * Google restates the past — a conversion is credited to the hour of its click, up to the
36
+ * conversion window (30 days by default) later — so the script re-reads a trailing window and the
37
+ * host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).
38
+ *
39
+ * @throws GoogleAdsReportError when the body is not a report.
40
+ */
41
+ export declare function parseGoogleAdsScriptReport(body: unknown): AdPerformanceRow[];
42
+ //#endregion
43
+ //# sourceMappingURL=google.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google.d.mts","names":["Infer"],"sources":["../../src/ads/google.ts"],"mappings":";;;cAoDM,iCAAY;;;;;;;;;;;;;;;;;yBAOhB;;YAGU,wBAAwBA,eAAa;;qBAGpC,6BAA6B;EACxC,YAAY;;;;;;;;;;;;;;;wBA0CE,2BAA2B,gBAAgB"}
@@ -0,0 +1,126 @@
1
+ import { channelGroupOf } from "../attribution/classify.mjs";
2
+ import "../attribution/index.mjs";
3
+ import { zonedHourToUtc } from "./time.mjs";
4
+ import { array, nullable, number, object, optional, safeParse, string, union } from "zod/mini";
5
+ //#region src/ads/google.ts
6
+ /**
7
+ * Google Ads delivery pushed by a Google Ads script: the script runs inside the ad account on
8
+ * Google's schedule, reads the hourly report with `AdsApp.search` and posts it to the host, which
9
+ * turns it into `AdPerformanceRow`s here. No developer token, manager account or OAuth client is
10
+ * needed — the script runs as the account's own user. The script is in the README.
11
+ *
12
+ * Google reports hours down to the ad group, not the ad (`segments.hour` is incompatible with
13
+ * `ad_group_ad`), and Performance Max campaigns have no ad groups at all; so a row is an ad group's
14
+ * hour, or a Performance Max campaign's, and `ad_id` holds that ad group or campaign id.
15
+ *
16
+ * https://developers.google.com/google-ads/scripts/docs/features/reports
17
+ * https://developers.google.com/google-ads/api/fields/v21/ad_group
18
+ */
19
+ /** What a Google ad click is in the attribution vocabulary: a `gclid` is `google / cpc`. */
20
+ const CHANNEL = "google";
21
+ const MEDIUM = "cpc";
22
+ /** `AdsApp.search` returns int64 metrics as strings and doubles as numbers; either is taken. */
23
+ const metric = optional(union([string(), number()]));
24
+ const reportRowSchema = object({
25
+ campaign_id: string(),
26
+ campaign_name: optional(nullable(string())),
27
+ /** Null for a Performance Max campaign's row. */
28
+ ad_group_id: optional(nullable(string())),
29
+ ad_group_name: optional(nullable(string())),
30
+ /** `segments.date`, a day in the account's time zone. */
31
+ date: string(),
32
+ /** `segments.hour`, 0–23, of that day. */
33
+ hour: number(),
34
+ cost_micros: metric,
35
+ impressions: metric,
36
+ clicks: metric,
37
+ conversions: metric,
38
+ conversion_value: metric
39
+ });
40
+ const reportSchema = object({
41
+ /** The customer id, digits only or as `123-456-7890`. */
42
+ account_id: string(),
43
+ currency: string(),
44
+ /** `AdsApp.currentAccount().getTimeZone()`, the IANA zone `date` and `hour` are in. */
45
+ time_zone: string(),
46
+ rows: array(reportRowSchema)
47
+ });
48
+ /** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */
49
+ var GoogleAdsReportError = class extends Error {
50
+ constructor(message) {
51
+ super(message);
52
+ this.name = "GoogleAdsReportError";
53
+ }
54
+ };
55
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
56
+ /** A metric as a number, absent as `fallback`; anything not numeric is an invalid report. */
57
+ function numeric(value, fallback, path) {
58
+ if (value === void 0) return fallback;
59
+ const n = Number(value);
60
+ if (!Number.isFinite(n)) throw new GoogleAdsReportError(`${path}: not a number: ${value}`);
61
+ return n;
62
+ }
63
+ function timeZoneOf(timeZone) {
64
+ try {
65
+ new Intl.DateTimeFormat("en-US", { timeZone });
66
+ } catch {
67
+ throw new GoogleAdsReportError(`Unknown time zone: ${timeZone}`);
68
+ }
69
+ return timeZone;
70
+ }
71
+ /**
72
+ * The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max
73
+ * campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from
74
+ * `cost_micros`. Google's `conversions` are its primary conversion actions under their own
75
+ * attribution model, click-based (view-through conversions are counted apart, without value),
76
+ * so they fill both the total and the click-through columns.
77
+ *
78
+ * Google restates the past — a conversion is credited to the hour of its click, up to the
79
+ * conversion window (30 days by default) later — so the script re-reads a trailing window and the
80
+ * host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).
81
+ *
82
+ * @throws GoogleAdsReportError when the body is not a report.
83
+ */
84
+ function parseGoogleAdsScriptReport(body) {
85
+ const parsed = safeParse(reportSchema, body);
86
+ if (!parsed.success) {
87
+ const issues = parsed.error.issues;
88
+ const first = issues.slice(0, 3).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
89
+ throw new GoogleAdsReportError(`Invalid Google Ads report (${issues.length} issues): ${first}${issues.length > 3 ? "; …" : ""}`);
90
+ }
91
+ const report = parsed.data;
92
+ const accountId = report.account_id.replaceAll("-", "");
93
+ const timeZone = timeZoneOf(report.time_zone);
94
+ return report.rows.map((row, index) => {
95
+ const path = `rows.${index}`;
96
+ if (!DATE.test(row.date) || !Number.isInteger(row.hour) || row.hour < 0 || row.hour > 23) throw new GoogleAdsReportError(`${path}: unexpected hour ${row.date} ${row.hour}`);
97
+ const conversions = numeric(row.conversions, null, `${path}.conversions`);
98
+ const value = numeric(row.conversion_value, null, `${path}.conversion_value`);
99
+ return {
100
+ platform: "google",
101
+ account_id: accountId,
102
+ campaign_id: row.campaign_id,
103
+ campaign_name: row.campaign_name ?? null,
104
+ ad_group_id: row.ad_group_id ?? null,
105
+ ad_group_name: row.ad_group_name ?? null,
106
+ ad_id: row.ad_group_id ?? row.campaign_id,
107
+ ad_name: null,
108
+ channel: CHANNEL,
109
+ medium: MEDIUM,
110
+ channel_group: channelGroupOf(CHANNEL, MEDIUM),
111
+ hour_start: zonedHourToUtc(row.date, row.hour, timeZone).toISOString(),
112
+ currency: report.currency,
113
+ spend: numeric(row.cost_micros, 0, `${path}.cost_micros`) / 1e6,
114
+ impressions: numeric(row.impressions, 0, `${path}.impressions`),
115
+ clicks: numeric(row.clicks, 0, `${path}.clicks`),
116
+ conversions,
117
+ conversion_value: value,
118
+ click_conversions: conversions,
119
+ click_conversion_value: value
120
+ };
121
+ });
122
+ }
123
+ //#endregion
124
+ export { GoogleAdsReportError, parseGoogleAdsScriptReport };
125
+
126
+ //# sourceMappingURL=google.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google.mjs","names":[],"sources":["../../src/ads/google.ts"],"sourcesContent":["/**\n * Google Ads delivery pushed by a Google Ads script: the script runs inside the ad account on\n * Google's schedule, reads the hourly report with `AdsApp.search` and posts it to the host, which\n * turns it into `AdPerformanceRow`s here. No developer token, manager account or OAuth client is\n * needed — the script runs as the account's own user. The script is in the README.\n *\n * Google reports hours down to the ad group, not the ad (`segments.hour` is incompatible with\n * `ad_group_ad`), and Performance Max campaigns have no ad groups at all; so a row is an ad group's\n * hour, or a Performance Max campaign's, and `ad_id` holds that ad group or campaign id.\n *\n * https://developers.google.com/google-ads/scripts/docs/features/reports\n * https://developers.google.com/google-ads/api/fields/v21/ad_group\n */\nimport {\n type infer as Infer,\n array,\n nullable,\n number,\n object,\n optional,\n safeParse,\n string,\n union,\n} from 'zod/mini';\nimport { channelGroupOf } from '../attribution';\nimport { zonedHourToUtc } from './time';\nimport type { AdPerformanceRow } from './types';\n\n/** What a Google ad click is in the attribution vocabulary: a `gclid` is `google / cpc`. */\nconst CHANNEL = 'google';\nconst MEDIUM = 'cpc';\n\n/** `AdsApp.search` returns int64 metrics as strings and doubles as numbers; either is taken. */\nconst metric = optional(union([string(), number()]));\n\nconst reportRowSchema = object({\n campaign_id: string(),\n campaign_name: optional(nullable(string())),\n /** Null for a Performance Max campaign's row. */\n ad_group_id: optional(nullable(string())),\n ad_group_name: optional(nullable(string())),\n /** `segments.date`, a day in the account's time zone. */\n date: string(),\n /** `segments.hour`, 0–23, of that day. */\n hour: number(),\n cost_micros: metric,\n impressions: metric,\n clicks: metric,\n conversions: metric,\n conversion_value: metric,\n});\n\nconst reportSchema = object({\n /** The customer id, digits only or as `123-456-7890`. */\n account_id: string(),\n currency: string(),\n /** `AdsApp.currentAccount().getTimeZone()`, the IANA zone `date` and `hour` are in. */\n time_zone: string(),\n rows: array(reportRowSchema),\n});\n\n/** The body the Google Ads script posts: the account and its report rows, as read. */\nexport type GoogleAdsScriptReport = Infer<typeof reportSchema>;\n\n/** A body that is not a `GoogleAdsScriptReport`; the host answers it with a 400. */\nexport class GoogleAdsReportError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GoogleAdsReportError';\n }\n}\n\nconst DATE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** A metric as a number, absent as `fallback`; anything not numeric is an invalid report. */\nfunction numeric<T extends number | null>(\n value: string | number | undefined,\n fallback: T,\n path: string\n): number | T {\n if (value === undefined) return fallback;\n const n = Number(value);\n if (!Number.isFinite(n)) throw new GoogleAdsReportError(`${path}: not a number: ${value}`);\n return n;\n}\n\nfunction timeZoneOf(timeZone: string): string {\n try {\n new Intl.DateTimeFormat('en-US', { timeZone });\n } catch {\n throw new GoogleAdsReportError(`Unknown time zone: ${timeZone}`);\n }\n return timeZone;\n}\n\n/**\n * The rows of a report a Google Ads script posted, validated, one per ad group (or Performance Max\n * campaign) and hour. The hours are the account's, turned into UTC instants. Spend comes from\n * `cost_micros`. Google's `conversions` are its primary conversion actions under their own\n * attribution model, click-based (view-through conversions are counted apart, without value),\n * so they fill both the total and the click-through columns.\n *\n * Google restates the past — a conversion is credited to the hour of its click, up to the\n * conversion window (30 days by default) later — so the script re-reads a trailing window and the\n * host upserts on (`platform`, `account_id`, `ad_id`, `hour_start`).\n *\n * @throws GoogleAdsReportError when the body is not a report.\n */\nexport function parseGoogleAdsScriptReport(body: unknown): AdPerformanceRow[] {\n const parsed = safeParse(reportSchema, body);\n if (!parsed.success) {\n const issues = parsed.error.issues;\n const first = issues\n .slice(0, 3)\n .map((issue) => `${issue.path.join('.')}: ${issue.message}`)\n .join('; ');\n throw new GoogleAdsReportError(\n `Invalid Google Ads report (${issues.length} issues): ${first}${issues.length > 3 ? '; …' : ''}`\n );\n }\n\n const report = parsed.data;\n const accountId = report.account_id.replaceAll('-', '');\n const timeZone = timeZoneOf(report.time_zone);\n return report.rows.map((row, index) => {\n const path = `rows.${index}`;\n if (!DATE.test(row.date) || !Number.isInteger(row.hour) || row.hour < 0 || row.hour > 23) {\n throw new GoogleAdsReportError(`${path}: unexpected hour ${row.date} ${row.hour}`);\n }\n const conversions = numeric(row.conversions, null, `${path}.conversions`);\n const value = numeric(row.conversion_value, null, `${path}.conversion_value`);\n return {\n platform: 'google',\n account_id: accountId,\n campaign_id: row.campaign_id,\n campaign_name: row.campaign_name ?? null,\n ad_group_id: row.ad_group_id ?? null,\n ad_group_name: row.ad_group_name ?? null,\n ad_id: row.ad_group_id ?? row.campaign_id,\n ad_name: null,\n channel: CHANNEL,\n medium: MEDIUM,\n channel_group: channelGroupOf(CHANNEL, MEDIUM),\n hour_start: zonedHourToUtc(row.date, row.hour, timeZone).toISOString(),\n currency: report.currency,\n spend: numeric(row.cost_micros, 0, `${path}.cost_micros`) / 1e6,\n impressions: numeric(row.impressions, 0, `${path}.impressions`),\n clicks: numeric(row.clicks, 0, `${path}.clicks`),\n conversions,\n conversion_value: value,\n click_conversions: conversions,\n click_conversion_value: value,\n };\n });\n}\n"],"mappings":"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"}
@@ -1,11 +1,14 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_ads_time = require("./time.cjs");
3
+ const require_ads_google = require("./google.cjs");
3
4
  const require_ads_meta = require("./meta.cjs");
5
+ exports.GoogleAdsReportError = require_ads_google.GoogleAdsReportError;
4
6
  exports.MetaAdsApiError = require_ads_meta.MetaAdsApiError;
5
7
  exports.addDays = require_ads_time.addDays;
6
8
  exports.addMonths = require_ads_time.addMonths;
7
9
  exports.fetchMetaAdPerformance = require_ads_meta.fetchMetaAdPerformance;
8
10
  exports.fetchMetaAdPerformanceHistory = require_ads_meta.fetchMetaAdPerformanceHistory;
11
+ exports.parseGoogleAdsScriptReport = require_ads_google.parseGoogleAdsScriptReport;
9
12
  exports.spread = require_ads_meta.spread;
10
13
  exports.todayIn = require_ads_time.todayIn;
11
14
  exports.zonedHourToUtc = require_ads_time.zonedHourToUtc;
@@ -1,4 +1,5 @@
1
1
  import { AdPerformanceRow, AdPlatform, ReportDate } from "./types.cjs";
2
+ import { GoogleAdsReportError, GoogleAdsScriptReport, parseGoogleAdsScriptReport } from "./google.cjs";
2
3
  import { MetaAdPerformanceOptions, MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread } from "./meta.cjs";
3
4
  import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.cjs";
4
- export { type AdPerformanceRow, type AdPlatform, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread, todayIn, zonedHourToUtc };
5
+ export { type AdPerformanceRow, type AdPlatform, GoogleAdsReportError, GoogleAdsScriptReport, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, parseGoogleAdsScriptReport, spread, todayIn, zonedHourToUtc };
@@ -1,4 +1,5 @@
1
1
  import { AdPerformanceRow, AdPlatform, ReportDate } from "./types.mjs";
2
+ import { GoogleAdsReportError, GoogleAdsScriptReport, parseGoogleAdsScriptReport } from "./google.mjs";
2
3
  import { MetaAdPerformanceOptions, MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread } from "./meta.mjs";
3
4
  import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.mjs";
4
- export { type AdPerformanceRow, type AdPlatform, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread, todayIn, zonedHourToUtc };
5
+ export { type AdPerformanceRow, type AdPlatform, GoogleAdsReportError, GoogleAdsScriptReport, MetaAdPerformanceOptions, MetaAdsApiError, type ReportDate, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, parseGoogleAdsScriptReport, spread, todayIn, zonedHourToUtc };
@@ -1,3 +1,4 @@
1
1
  import { addDays, addMonths, todayIn, zonedHourToUtc } from "./time.mjs";
2
+ import { GoogleAdsReportError, parseGoogleAdsScriptReport } from "./google.mjs";
2
3
  import { MetaAdsApiError, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread } from "./meta.mjs";
3
- export { MetaAdsApiError, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, spread, todayIn, zonedHourToUtc };
4
+ export { GoogleAdsReportError, MetaAdsApiError, addDays, addMonths, fetchMetaAdPerformance, fetchMetaAdPerformanceHistory, parseGoogleAdsScriptReport, spread, todayIn, zonedHourToUtc };
@@ -1,7 +1,7 @@
1
1
  import { ChannelGroup } from "../attribution/vocabulary.cjs";
2
2
  //#region src/ads/types.d.ts
3
3
  /** The ad platforms whose reporting APIs this module reads. */
4
- export type AdPlatform = 'meta';
4
+ export type AdPlatform = 'meta' | 'google';
5
5
  /**
6
6
  * One row of ad delivery: one ad over one hour. The row a host stores in its `ad_performance`
7
7
  * table, unique on (`platform`, `account_id`, `ad_id`, `hour_start`).
@@ -18,13 +18,17 @@ export type AdPlatform = 'meta';
18
18
  export interface AdPerformanceRow {
19
19
  /** The reporting API the row came from; `channel` is what it joins attribution on. */
20
20
  platform: AdPlatform;
21
- /** The platform's account id, without a prefix (Meta's `act_` is stripped). */
21
+ /** The platform's account id, without a prefix or dashes (Meta's `act_`, Google's `123-`). */
22
22
  account_id: string;
23
23
  campaign_id: string;
24
24
  campaign_name: string | null;
25
- /** The level between campaign and ad: Meta's ad set. */
25
+ /** The level between campaign and ad: Meta's ad set, Google's ad group. */
26
26
  ad_group_id: string | null;
27
27
  ad_group_name: string | null;
28
+ /**
29
+ * The finest level the platform reports hours at: the ad on Meta; on Google the ad group, or the
30
+ * campaign for Performance Max, which has none.
31
+ */
28
32
  ad_id: string;
29
33
  ad_name: string | null;
30
34
  channel: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.cts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
1
+ {"version":3,"file":"types.d.cts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;;;;;EAKA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
@@ -1,7 +1,7 @@
1
1
  import { ChannelGroup } from "../attribution/vocabulary.mjs";
2
2
  //#region src/ads/types.d.ts
3
3
  /** The ad platforms whose reporting APIs this module reads. */
4
- export type AdPlatform = 'meta';
4
+ export type AdPlatform = 'meta' | 'google';
5
5
  /**
6
6
  * One row of ad delivery: one ad over one hour. The row a host stores in its `ad_performance`
7
7
  * table, unique on (`platform`, `account_id`, `ad_id`, `hour_start`).
@@ -18,13 +18,17 @@ export type AdPlatform = 'meta';
18
18
  export interface AdPerformanceRow {
19
19
  /** The reporting API the row came from; `channel` is what it joins attribution on. */
20
20
  platform: AdPlatform;
21
- /** The platform's account id, without a prefix (Meta's `act_` is stripped). */
21
+ /** The platform's account id, without a prefix or dashes (Meta's `act_`, Google's `123-`). */
22
22
  account_id: string;
23
23
  campaign_id: string;
24
24
  campaign_name: string | null;
25
- /** The level between campaign and ad: Meta's ad set. */
25
+ /** The level between campaign and ad: Meta's ad set, Google's ad group. */
26
26
  ad_group_id: string | null;
27
27
  ad_group_name: string | null;
28
+ /**
29
+ * The finest level the platform reports hours at: the ad on Meta; on Google the ad group, or the
30
+ * campaign for Performance Max, which has none.
31
+ */
28
32
  ad_id: string;
29
33
  ad_name: string | null;
30
34
  channel: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.mts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
1
+ {"version":3,"file":"types.d.mts","names":[],"sources":["../../src/ads/types.ts"],"mappings":";;;YAGY;;;;;;;;;;;;;;iBAeK;;EAEf,UAAU;;EAEV;EACA;EACA;;EAEA;EACA;;;;;EAKA;EACA;EACA;EACA;EACA,eAAe;;EAEf;;EAEA;EACA;EACA;;;;;EAKA;;;;;EAKA;EACA;;EAEA;EACA;;;YAIU"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/analytics",
3
- "version": "11.9.0",
3
+ "version": "11.10.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",