@koda-sl/baker-cli 0.269.0 → 0.270.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
@@ -1232,6 +1232,10 @@ baker analytics tracking # do the ad URLs carry the campaign, a
1232
1232
  baker analytics tracking --platform meta # the same for one platform, read from their own ad account
1233
1233
  baker analytics map --platform google --set kw=keyword # name a parameter, seen or not yet
1234
1234
  baker analytics map --platform google --remove kw # take that answer back
1235
+ baker analytics conversions # what counts as a conversion, and what each one produced
1236
+ baker analytics conversions --candidates # everything these pages do, and which of it is counted
1237
+ baker analytics conversions --event page:request_demo --name "Demo requested"
1238
+ baker analytics conversions --remove page:request_demo
1235
1239
  baker analytics delivery --page 2 # the next page of a long list
1236
1240
  baker analytics submissions --flow contact # every attempt at a Form, delivered or not
1237
1241
  ```
@@ -1246,6 +1250,8 @@ Shared flags: `--days <n>` (default 30) or `--start-date` / `--end-date` (`YYYY-
1246
1250
 
1247
1251
  **Paging the list reports.** `people`, `submissions` and `delivery` are lists of individual things rather than breakdowns — `delivery` and `submissions` are the ones the CLI exposes, `people` backs the dashboard's People screen over the same wire — and only they take `--page <n>` (1-based) and `--page-size <n>` (up to 200, default 50). The response carries `pageInfo` with `hasMore` — when it is true there **are** more rows, so a total must never be reported from one page. Pass neither flag and a list comes back at the size it always did.
1248
1252
 
1253
+ **A conversion is a company-level named event, not a property of a Form.** `baker analytics conversions` is the only place "what counts" is decided, and an empty `definitions` list means every conversion number in every other report is zero — not because nobody converted, but because nothing is named as an outcome and Baker never guesses which event is the point of a page. `--candidates` lists every event these pages actually produced — each Form step and trigger, every `data-baker-*` event, every outbound destination — with volume and whether it is already counted; that list is where an event key comes from, so never invent one. `--event <key> --name "Booked a call"` starts counting it **immediately and retroactively**: the whole stored history is rescored, so naming the right event today also fixes last month. Nothing here is staged and publishing is not involved. Two events given the same `--name` become one row and one number, which is how a call booked on three different Forms reads as one outcome. `--count-mode every_time` is for the outcomes people genuinely repeat (a guide downloaded twice is two downloads); the default counts once per visit. One outcome per call — repeating `--event` is refused rather than silently keeping the last one.
1254
+
1249
1255
  **Campaign parameters are per platform, and so are the numbers.** `baker analytics ads` returns `adPlatformTraffic` (visits, conversions and *tagged* visits for each of the nine platforms), `adParams` for the platform in `--platform` only, and `adParamsUnattributed` for names arriving on visits with no click id and no recognisable source. Read `adPlatformTraffic` first: a platform with visits and zero tagged visits has untagged ad URLs, and no mapping can fix that from Baker's side. `baker analytics map` needs `--platform` for any change, accepts a parameter name **nobody has sent yet** — which is how a tracking template is configured before its campaign runs — and takes an answer back with `--remove`. `--set <name>=ignore` says a parameter is not campaign information at all, so Baker stops storing it and stops listing it.
1250
1256
 
1251
1257
  **Mapping is staged on the chat, not applied.** Answers appear under **Campaign parameters** in the dashboard and take effect when the chat is published; a discard takes them all back, and a report run in the same turn still reads the old vocabulary. Reading the mapping back inside the chat *does* include what it has staged, so a second turn never re-maps the same parameter.
package/dist/cli.js CHANGED
@@ -12291,6 +12291,7 @@ var CAPTURED_WITHOUT_ROLE = [
12291
12291
  ].sort();
12292
12292
 
12293
12293
  // ../api/src/analytics/conversions.ts
12294
+ var CONVERSION_COUNT_MODES = ["once_per_visit", "every_time"];
12294
12295
  var PRIMARY_CONVERSION_TRIGGER = {
12295
12296
  calendly: "calendlyEvent.eventScheduled",
12296
12297
  customForm: "customForm.onFormSubmitted",
@@ -12306,6 +12307,33 @@ var NON_SUBMIT_NODE_TYPES = ["link", "text"];
12306
12307
  var STEP_SUBMIT_TRIGGERS = Object.fromEntries(
12307
12308
  Object.entries(PRIMARY_CONVERSION_TRIGGER).filter(([nodeType]) => !NON_SUBMIT_NODE_TYPES.includes(nodeType))
12308
12309
  );
12310
+ var MAX_CONVERSION_NAME = 80;
12311
+
12312
+ // ../api/src/analytics/eventKey.ts
12313
+ var SEP = ":";
12314
+ function parseEventKey(key) {
12315
+ const cut = key.indexOf(SEP);
12316
+ if (cut <= 0) return null;
12317
+ const family = key.slice(0, cut);
12318
+ const rest = key.slice(cut + 1);
12319
+ if (rest === "") return null;
12320
+ switch (family) {
12321
+ case "form": {
12322
+ const [flowSlug, nodeId, ...triggerParts] = rest.split(SEP);
12323
+ const triggerId = triggerParts.join(SEP);
12324
+ if (!flowSlug || !nodeId || triggerId === "") return null;
12325
+ return { family: "form", flowSlug, nodeId, triggerId };
12326
+ }
12327
+ case "submit":
12328
+ return { family: "submit", flowSlug: rest };
12329
+ case "page":
12330
+ return { family: "page", eventName: rest };
12331
+ case "exit":
12332
+ return { family: "exit", host: rest };
12333
+ default:
12334
+ return null;
12335
+ }
12336
+ }
12309
12337
 
12310
12338
  // ../api/src/analytics/events.ts
12311
12339
  import { z as z24 } from "zod";
@@ -12817,7 +12845,18 @@ var analyticsPresetSchema = z25.enum([
12817
12845
  * would do is add a choice the agent can never usefully pick to the menu it
12818
12846
  * reads before picking one.
12819
12847
  */
12820
- "realtime"
12848
+ "realtime",
12849
+ /**
12850
+ * What this company counts as a conversion, and everything it could count.
12851
+ *
12852
+ * The two halves of one question, which is why they are one preset: the list
12853
+ * of definitions with what each produced, and the catalogue of every event
12854
+ * these pages actually emitted with whether it is already counted. Reading
12855
+ * the first without the second is how the previous design failed — a
12856
+ * marketer could see that nothing was counted and had no way to find out what
12857
+ * was available to count.
12858
+ */
12859
+ "conversions"
12821
12860
  ]);
12822
12861
  var ANALYTICS_PRESETS = analyticsPresetSchema.options;
12823
12862
  var analyticsQueryRequestSchema = z25.object({
@@ -13478,21 +13517,40 @@ var analyticsFlowEdgeSchema = z25.object({
13478
13517
  sessions: z25.number().int().nonnegative()
13479
13518
  });
13480
13519
  var analyticsConversionRowSchema = z25.object({
13481
- /** What a person calls this outcome. Falls back to the node's name. */
13520
+ /** What a person calls this outcome. */
13482
13521
  name: z25.string(),
13483
- flowSlug: z25.string(),
13484
- nodeId: z25.string(),
13485
- triggerId: z25.string(),
13486
- nodeType: z25.string(),
13487
- /** `once_per_visit` | `every_time` — how repeats inside one visit are counted. */
13522
+ /** Every event counted under this name. One entry unless the name is shared. */
13523
+ eventKeys: z25.array(z25.string()),
13524
+ /**
13525
+ * `once_per_visit` | `every_time` — how repeats inside one visit are counted.
13526
+ *
13527
+ * `mixed` when a shared name spans both, which is a real state and an editing
13528
+ * accident; saying so is what makes it findable.
13529
+ */
13488
13530
  countMode: z25.string(),
13489
- /** Counted the way this mark asked to be counted. */
13531
+ /** Counted the way each definition asked to be counted. */
13490
13532
  conversions: z25.number(),
13491
13533
  /** Distinct visits that produced it, never mode-adjusted. The gap between the two is the repeat behaviour. */
13492
13534
  convertingVisits: z25.number(),
13493
13535
  /** Null when it has never fired. */
13494
13536
  lastSeen: z25.string().nullable()
13495
13537
  });
13538
+ var analyticsEventCatalogRowSchema = z25.object({
13539
+ /** `form:…` | `submit:…` | `page:…` | `exit:…` — parse with `parseEventKey`. */
13540
+ eventKey: z25.string(),
13541
+ events: z25.number(),
13542
+ sessions: z25.number(),
13543
+ /** The Form node's kind, for a `form:` key. Empty otherwise. */
13544
+ nodeType: z25.string(),
13545
+ /** The Form this happened in, for a `form:` or `submit:` key. Empty otherwise. */
13546
+ flowSlug: z25.string(),
13547
+ /** Null when it has never fired — only possible for a row we already count. */
13548
+ lastSeen: z25.string().nullable(),
13549
+ /** Already counted as a conversion. Shown, never hidden: a row that vanished on being counted reads as a bug. */
13550
+ isCounted: z25.boolean(),
13551
+ /** The name it is counted under, when it is. */
13552
+ countedAs: z25.string()
13553
+ });
13496
13554
  var analyticsAdDimensionRowSchema = z25.object({
13497
13555
  /** The dimension's value. Empty means "arrived carrying none of these". */
13498
13556
  value: z25.string(),
@@ -13833,6 +13891,8 @@ var analyticsQueryDataSchema = z25.object({
13833
13891
  /** Present only on a report that was asked for by the page. */
13834
13892
  pageInfo: analyticsPageInfoSchema.optional(),
13835
13893
  conversions: z25.array(analyticsConversionRowSchema).optional(),
13894
+ /** Every event these pages produced, counted or not. The "add a conversion" picker. */
13895
+ eventCatalog: z25.array(analyticsEventCatalogRowSchema).optional(),
13836
13896
  timeline: z25.array(analyticsTimelineRowSchema).optional(),
13837
13897
  visitorSessions: z25.array(analyticsVisitorSessionSchema).optional(),
13838
13898
  visitProfile: analyticsVisitProfileSchema.optional(),
@@ -13855,6 +13915,31 @@ var analyticsQueryResponseSchema = z25.object({
13855
13915
  warnings: z25.array(analyticsWarningSchema).optional(),
13856
13916
  hints: z25.array(z25.string()).optional()
13857
13917
  });
13918
+ var analyticsConversionsRequestSchema = z25.object({
13919
+ set: z25.array(
13920
+ z25.object({
13921
+ /** `form:<flow>:<step>:<trigger>` | `submit:<flow>` | `page:<name>` | `exit:<host>`. */
13922
+ eventKey: z25.string().max(320),
13923
+ name: z25.string().max(MAX_CONVERSION_NAME),
13924
+ countMode: z25.enum(CONVERSION_COUNT_MODES).optional()
13925
+ })
13926
+ ).max(60).optional(),
13927
+ remove: z25.array(z25.string().max(320)).max(60).optional()
13928
+ });
13929
+ var analyticsConversionsResponseSchema = z25.object({
13930
+ ok: z25.literal(true),
13931
+ data: z25.object({
13932
+ definitions: z25.array(
13933
+ z25.object({
13934
+ eventKey: z25.string(),
13935
+ name: z25.string(),
13936
+ countMode: z25.string(),
13937
+ updatedAt: z25.number()
13938
+ })
13939
+ )
13940
+ }),
13941
+ hints: z25.array(z25.string()).optional()
13942
+ });
13858
13943
  var analyticsMappingRequestSchema = z25.object({
13859
13944
  /**
13860
13945
  * Which platform these operations are about. Required for a change.
@@ -25004,6 +25089,88 @@ Full guides: __tooling__/docs/tools/baker/ads-<platform>.md (google|meta|linkedi
25004
25089
  // src/commands/analytics/index.ts
25005
25090
  import { defineCommand as defineCommand90 } from "citty";
25006
25091
 
25092
+ // src/commands/analytics/conversionArgs.ts
25093
+ var ConversionArgsError = class extends Error {
25094
+ };
25095
+ function onlyOnce(value, flag, argv) {
25096
+ if (value === void 0) return void 0;
25097
+ const given = argv.filter((arg) => arg === flag || arg.startsWith(`${flag}=`)).length;
25098
+ if (given > 1) {
25099
+ throw new ConversionArgsError(
25100
+ `${flag} was given ${given} times and only the last would have been used. Count one outcome per call.`
25101
+ );
25102
+ }
25103
+ const trimmed = String(value).trim();
25104
+ return trimmed === "" ? void 0 : trimmed;
25105
+ }
25106
+ function parseConversionsArgs(raw, argv) {
25107
+ const eventKey = onlyOnce(raw.event, "--event", argv);
25108
+ const name = onlyOnce(raw.name, "--name", argv);
25109
+ const countMode = onlyOnce(raw["count-mode"], "--count-mode", argv);
25110
+ const remove = onlyOnce(raw.remove, "--remove", argv);
25111
+ if (eventKey !== void 0 && name === void 0) {
25112
+ throw new ConversionArgsError("--event needs --name: a conversion nobody named is a row labelled with a UUID.");
25113
+ }
25114
+ if (name !== void 0 && eventKey === void 0 && remove === void 0) {
25115
+ throw new ConversionArgsError("--name needs --event, naming which event to count.");
25116
+ }
25117
+ if (eventKey !== void 0 && parseEventKey(eventKey) === null) {
25118
+ throw new ConversionArgsError(
25119
+ `"${eventKey}" is not an event key. Run with --candidates and copy one; they look like form:<form>:<step>:<trigger>, submit:<form>, page:<name> or exit:<host>.`
25120
+ );
25121
+ }
25122
+ if (countMode !== void 0 && !CONVERSION_COUNT_MODES.includes(countMode)) {
25123
+ throw new ConversionArgsError(`--count-mode must be one of ${CONVERSION_COUNT_MODES.join(", ")}.`);
25124
+ }
25125
+ const days = raw.days === void 0 ? void 0 : Number(raw.days);
25126
+ if (days !== void 0 && (!Number.isFinite(days) || days <= 0)) {
25127
+ throw new ConversionArgsError("--days must be a positive number of days.");
25128
+ }
25129
+ return { eventKey, name, countMode, remove, candidates: raw.candidates === true, days };
25130
+ }
25131
+ function conversionsWrite(args) {
25132
+ const set = args.eventKey !== void 0 && args.name !== void 0 ? [{ eventKey: args.eventKey, name: args.name, ...args.countMode ? { countMode: args.countMode } : {} }] : void 0;
25133
+ const remove = args.remove !== void 0 ? [args.remove] : void 0;
25134
+ if (!set && !remove) return null;
25135
+ return { ...set ? { set } : {}, ...remove ? { remove } : {} };
25136
+ }
25137
+
25138
+ // src/commands/analytics/conversionHints.ts
25139
+ var NAMED_CANDIDATES = 3;
25140
+ function conversionHints(definitions, candidates, askedForCandidates) {
25141
+ const hints2 = [];
25142
+ if (definitions.length === 0) {
25143
+ hints2.push(
25144
+ "Nothing is counted as a conversion, so EVERY conversion number in every other report is zero \u2014 whatever visitors actually did. Baker never guesses which event is the point of a page. Run `baker analytics conversions --candidates` to see what these pages produce, then count the ones that are worth money."
25145
+ );
25146
+ if (!askedForCandidates && candidates.length > 0) {
25147
+ const top = candidates.slice(0, NAMED_CANDIDATES).map((row) => row.eventKey).join(", ");
25148
+ hints2.push(`${candidates.length} distinct events are available to count. Busiest: ${top}.`);
25149
+ }
25150
+ return hints2;
25151
+ }
25152
+ const silent = definitions.filter((row) => row.lastSeen === null);
25153
+ if (silent.length > 0) {
25154
+ hints2.push(
25155
+ `${silent.length} counted outcome(s) have never fired in this window: ${silent.slice(0, NAMED_CANDIDATES).map((row) => row.name).join(
25156
+ ", "
25157
+ )}. Either the page never reaches them, or the wrong event was named \u2014 check against --candidates before reporting a conversion rate.`
25158
+ );
25159
+ }
25160
+ const uncounted = candidates.filter((row) => !row.isCounted);
25161
+ const busiest = uncounted[0];
25162
+ if (busiest && busiest.sessions > 0) {
25163
+ const counted = candidates.filter((row) => row.isCounted);
25164
+ const busiestCounted = counted.reduce((max, row) => Math.max(max, row.sessions), 0);
25165
+ if (busiest.sessions > busiestCounted) {
25166
+ hints2.push(
25167
+ `\`${busiest.eventKey}\` happened in ${busiest.sessions} visits and is not counted \u2014 more than anything that is. Check it is not the real outcome.`
25168
+ );
25169
+ }
25170
+ }
25171
+ return hints2;
25172
+ }
25173
+
25007
25174
  // src/commands/analytics/hints.ts
25008
25175
  var SEVERE_STEP_DROP = 0.6;
25009
25176
  var MIN_SESSIONS_FOR_RATES = 100;
@@ -25183,6 +25350,11 @@ var ANALYTICS_PRESET_INFO = [
25183
25350
  name: "releases",
25184
25351
  description: "Traffic and conversions per published build, newest first. Answers whether a change to a page moved the numbers.",
25185
25352
  playbook: "landing \u2014 compare a page before and after a change"
25353
+ },
25354
+ {
25355
+ name: "conversions",
25356
+ description: "What this company counts as a conversion, and what each one produced. The report every other conversion number is derived from, so read it FIRST whenever a conversion figure looks wrong or reads zero: an empty list means nothing is counted and every conversion number everywhere is zero, whatever visitors did. `baker analytics conversions --candidates` lists everything these pages actually produce \u2014 every Form step, every data-baker-* event, every outbound destination \u2014 and counting one applies immediately and rescores the whole history, with no publish. Anything can be counted, not just a Form.",
25357
+ playbook: "run `baker analytics conversions` \u2014 this one is fixed with the command, not with a skill"
25186
25358
  }
25187
25359
  ];
25188
25360
 
@@ -25855,6 +26027,79 @@ Examples:
25855
26027
  }
25856
26028
  });
25857
26029
  })();
26030
+ var conversionsCommand3 = (() => {
26031
+ const args = {
26032
+ event: {
26033
+ type: "string",
26034
+ description: "The event to start counting, from the `candidates` list: form:<form>:<step>:<trigger>, submit:<form>, page:<name>, or exit:<host>. Requires --name. Applies immediately and scores the WHOLE history, not just from now on \u2014 so counting the right event today also corrects last month's reports",
26035
+ required: false
26036
+ },
26037
+ name: {
26038
+ type: "string",
26039
+ description: "What to call this outcome on every report \u2014 'Booked a call', 'Quote requested'. Reuse the SAME name on two events and they become one row and one number, which is how a call booked on three different Forms reads as one outcome rather than three",
26040
+ required: false
26041
+ },
26042
+ "count-mode": {
26043
+ type: "string",
26044
+ description: "once_per_visit (default) or every_time. Ask whether doing it twice in one visit is one outcome or two: a booking clicked twice is one booking, a guide downloaded twice is two downloads. Getting this wrong inflates the rate every budget is set by",
26045
+ required: false
26046
+ },
26047
+ remove: {
26048
+ type: "string",
26049
+ description: "An event key to stop counting. Removes it from the history too, for the same reason adding it fills the history in",
26050
+ required: false
26051
+ },
26052
+ candidates: {
26053
+ type: "boolean",
26054
+ description: "Also list every event these pages actually produced, ranked by volume, with whether it is already counted. This is where an event key comes from \u2014 do not invent one. Off by default because a busy company produces hundreds of rows",
26055
+ required: false
26056
+ },
26057
+ days: {
26058
+ type: "string",
26059
+ description: "Lookback for the counts and for --candidates. Default 30",
26060
+ required: false
26061
+ }
26062
+ };
26063
+ registerSchema({
26064
+ command: "analytics.conversions",
26065
+ description: "Read or change what this company counts as a conversion",
26066
+ args
26067
+ });
26068
+ return defineCommand90({
26069
+ meta: {
26070
+ name: "conversions",
26071
+ description: "What this company counts as a conversion, and what each one produced. Run with no flags first: an empty `definitions` list means every conversion number in every other report is zero \u2014 not because nobody converted, but because nothing is marked as converting, and Baker never guesses. Add `--candidates` to see everything these pages actually do (every Form step, every data-baker-* event, every outbound destination) with counts and whether it is already counted; that list is where an event key comes from, so never invent one. Then `--event <key> --name 'Booked a call'` to count it. It applies IMMEDIATELY and retroactively \u2014 the whole history is rescored, so a company that has been running for a month gets a month of correct numbers the moment you name the right event. Nothing here is staged and publishing is not involved. Two events given the SAME --name are one row and one number. `--remove <key>` stops counting one."
26072
+ },
26073
+ args,
26074
+ run: async ({ args: raw }) => {
26075
+ try {
26076
+ const args2 = parseConversionsArgs(raw, process.argv);
26077
+ const write2 = conversionsWrite(args2);
26078
+ if (write2) {
26079
+ await apiPost("/api/analytics/conversions", write2);
26080
+ }
26081
+ const report = await apiPost("/api/analytics/query", {
26082
+ preset: "conversions",
26083
+ days: args2.days
26084
+ });
26085
+ const definitions = report.data.conversions ?? [];
26086
+ const candidates = report.data.eventCatalog ?? [];
26087
+ const hints2 = conversionHints(definitions, candidates, args2.candidates);
26088
+ writeJsonEnvelope({
26089
+ ok: true,
26090
+ data: { definitions, ...args2.candidates ? { candidates } : {} },
26091
+ ...hints2.length > 0 ? { hints: hints2 } : {}
26092
+ });
26093
+ } catch (err) {
26094
+ if (err instanceof ConversionArgsError) {
26095
+ handleError(new ApiError("VALIDATION_ERROR", err.message));
26096
+ return;
26097
+ }
26098
+ handleError(err);
26099
+ }
26100
+ }
26101
+ });
26102
+ })();
25858
26103
  function parsePairs(raw) {
25859
26104
  if (raw === void 0) return void 0;
25860
26105
  const out = {};
@@ -25929,6 +26174,7 @@ Full guide: __tooling__/docs/tools/baker/analytics.md`
25929
26174
  events: eventsCommand,
25930
26175
  devices: devicesCommand,
25931
26176
  ads: adsCommand3,
26177
+ conversions: conversionsCommand3,
25932
26178
  map: mapCommand,
25933
26179
  tracking: trackingCommand,
25934
26180
  presets: presetsCommand