Analytics & ROI
Merchant analytics must prove Tryvio impact without broad attribution. The merchant-facing revenue metric is Tryvio attributed revenue: revenue from purchased line items whose product has a Tryvio proof row for the same shopper inside the attribution window.
Current production state
Shipped web/API release: 29cad2a fix(analytics): ship ROI dashboard metrics.
The merchant Overview now includes a Tryvio ROI panel and proof drawer. The API response from
/api/shopify/metrics includes:
totals: funnel, attribution, Shopify order/revenue context, and comparison values.valueMetrics: attributed revenue, Tryvio cost, ROAS, ROI, and estimated lift states.proofRows: merchant-safe attributed line proof rows.billing: active plan state and usage counters.
Since #149 the computation lives in lib/server/merchant-metrics-bundle.ts (computeMerchantMetrics):
/api/shopify/metrics calls it and then runs the merchant-only follow-ups (web pixel, billing reconcile,
notifications); the operator admin calls the same function through /api/admin/store-merchant-metrics
(“view as merchant”), so the admin’s ROI can never differ from the merchant’s.
The dashboard is client-rendered inside Shopify Admin, and /api/shopify/metrics is authenticated
through the embedded app session. Public curl checks will return 401; verify live data from an
authenticated Shopify Admin iframe or through controlled server-side diagnostics.
Source of truth
- The widget records a tried-product proof after a successful result view.
/api/eventspersists that proof intotried_products.- Shopify pixel checkout events are normalized per line item.
/api/eventswrites one row per attributed purchased line intoattributed_order_lines.attributed_ordersremains a backward-compatible summary table for existing dashboard readers.
analytics_events is the funnel source. As of 2026-07-10 all event-derived dashboard metrics are
computed in SQL over the full date range; since 2026-07-28 the app calls
merchant_metrics_event_totals_v3(p_store_id, p_from, p_to) (the single-pass v2 rewrite with
impressions pulled off the heavy scan) — see
SQL aggregation (2026-07-10, v2 2026-07-27) below.
listRecentAnalyticsEvents() (the old 50,000-row JS-side fetch) has been removed from all metrics
computation; a targeted per-session fetch is still used for proof-drawer event chains.
Sessions and baseline source
Store-level sessions, store conversion rate, and the baseline used for estimated lift are derived
from Tryvio’s own Shopify web pixel, not from ShopifyQL. Each distinct product_viewed event
fired by the pixel counts as one shopper session. The previous ShopifyQL FROM sessions approach
required the read_reports scope and Shopify Protected Customer Data (Level 2) approval; these
are unavailable on most plans and consistently returned unavailable / 0.
Total orders and total revenue continue to come from the Shopify Orders API (unchanged).
Baseline conversion = the non-Tryvio cohort: shoppers who fired product_viewed but did not
trigger any Tryvio interaction. Their purchase conversion rate is compared with the Tryvio
cohort’s rate to produce the estimated lift figure. Sample-size guards still apply: the card
shows Collecting data when either cohort is too small, and Not enough baseline traffic yet
when the non-Tryvio baseline cohort is empty.
Attribution rules
| Case | Counts? | Rule |
|---|---|---|
| Shopper tries product A, buys A | Yes | Same product proof exists |
| Shopper tries product A, buys B | No | Different product |
| Shopper tries bundle A+B+C, buys only B | Yes, B line only | Bundle creates proof for each product |
| Shopper tries bundle A+B+C, buys D | No | D has no proof |
| Shopper tries related product B, buys B | Yes | Related try-on creates its own proof |
| Shopper tries A, returns 3 weeks later and buys A | Yes | 30-day product lookback |
Old pixel 7d_lookback with no proof | No | Broad lookback is not trusted |
The attribution window is 30 days. V1 Shopify historical reconciliation stays within Shopify’s default 60-day order access unless the app later requests broader scopes.
Proof payload
tried_products stores:
store_idtryon_session_idshopper_session_id,shopper_pseudo_idproduct_id,product_handle,external_product_id,external_variant_idrole:primary,bundle_complement,related_product,gallerysource:main_product,bundle_outfit,related_product,gallery,servertried_at,expires_atattribution_keyfor idempotent writesmetadata
attributed_order_lines stores the line-level purchase proof:
order_id,order_line_id, optionalorder_name- product identifiers for the purchased line
quantity,line_value,currencytryon_session_id, optionaltried_product_idattribution_mode:same_session_same_productor30d_product_lookbackproofJSON showing the matched tried-product timestamp, role, and sourceidempotency_keyso repeated pixel events do not duplicate revenue
tryon_widget_error (#410)
A client-side widget failure used to be invisible in the DB — the Kylyan 21.08 incident was diagnosed by
guessing. Since #410 the widget posts tryon_widget_error (source storefront, via navigator.sendBeacon
with a fetch keepalive fallback) with eventPayload = { stage, message, version, ua }:
stage | When |
|---|---|
config | /api/proxy rejected / non-OK / non-JSON after the single retry (message = Failed to fetch / config http 503 / config not json) |
boot | the config resolved but stage 2’s post-config work threw |
stage2 | tryvio-boot.js failed to load |
modal_load | tryvio-widget.js failed to load on click |
modal_open | runModal threw |
version is the widget build (tryvio-ai-NNN), ua the raw user agent (≤300 chars — diagnostic, not a
segmentation field; it does not ride ctx). The ingest route stores it as any other event
(no allow-list; shopDomain + productHandle are carried so the row is store- and product-scoped). No
metric counts it; query it directly, e.g.
select occurred_at, event_payload->>'stage', event_payload->>'message' from analytics_events where event_type = 'tryon_widget_error' and store_id = … order by occurred_at desc.
Widget event context (ctx, #304)
Every widget UX event — tryon_widget_impression, tryon_widget_click, tryon_modal_opened —
carries an additive eventPayload.ctx object, so CTR and other funnel metrics can be segmented
(e.g. mobile vs. desktop, or by button variant) without a new table. Born from the #302 post-mortem:
a mobile-only default change halved click-through and the data at the time could not even confirm it
was mobile. Fields:
| Field | Values |
|---|---|
device | mobile | tablet | desktop | unknown |
vw (viewport bucket) | lt360 | 360_519 | 520_767 | 768_1023 | 1024_1439 | gte1440 | unknown — 520 is deliberately a bucket edge: it’s the widget’s own mobile breakpoint (MOBILE_MAX in tryvio-collection.js), so lt360 ∪ 360_519 is exactly “the population that got the mobile defaults” |
page | product | collection | home | search | other | unknown |
btnKind | pdp | card | unknown |
btnTier | mark | compact | full | none | unknown |
btnLabel | shown | hidden | unknown |
btnScale | 70–150, snapped to steps of 10 (the merchant’s card-size slider) | unknown |
btnAttn (#325) | on | off | unknown — whether the attention cue is enabled for this root, so its effect on CTR is segmentable |
Every field can also be the literal string "unknown".
Senders. tryvio-boot.js’s widgetCtx(root) computes the context once per root, inside
kit.run, and it rides three events end-to-end: (1) the tryon_widget_impression payload, (2)
modalConfig.buttonContext → the modal’s session POST body as context → the server stamps a
normalized copy onto the server-emitted tryon_widget_click (/api/storefront/session), (3) the
tryon_modal_opened payload (fired by the modal itself).
Normalization. The pure module apps/web/src/lib/widget/widget-event-context.ts exports
computeWidgetEventContext (the tested widget-side spec — mirrored by widgetCtx in
tryvio-boot.js; keep the two in parity) and normalizeWidgetEventContext (the server-side ingest
clamp). Every field is forced onto its whitelisted enum: absent (an old cached widget that predates
#304 sends no ctx at all) or foreign (a forged client) becomes the explicit string "unknown",
never a default that would silently skew a segment; btnScale is clamped to 70–150 and snapped to
steps of 10 so a forged client cannot explode cardinality. /api/events normalizes ctx for
tryon_widget_impression and tryon_modal_opened only; /api/storefront/session normalizes it for
the server-emitted tryon_widget_click. Old, cached widgets are never dropped — their rows are
stored with every ctx field "unknown" (additive, backward-compatible contract).
Root attributes. Card roots carry data-tryvio-page-type (liquid request.page_type) and
data-tryvio-attention, both stamped by tryvio-collection.js’s buildRoot; PDP launcher roots get
data-tryvio-attention from the liquid snippet.
Impression de-dup (#354)
One tryon_widget_impression per (product handle × launcher scope) per page load.
tryvio-boot.js’s firstImpressionFor(root, productHandle) holds that memory for the lifetime of
the page; kit.run posts the impression only on the first call for a key.
The rule exists because a theme that re-renders the product section hands the widget a fresh copy of the launcher, which #354 then arms — without the rule, every variant change would buy another impression for the same button and inflate the denominator of the CTR that #302/#304/#325 are judged on. Measured on the #354 fixture: 4 impressions for 1 shopper-visible button across 3 variant changes before, 1 after.
What the rule deliberately still counts: a different product (SPA route change, a collection card —
each card has its own tryvio-card-<handle> scope), an app embed and a hand-placed block on the
same page (different scopes), and any genuine second page view (the memory is page-scoped). What it
merges: the same product rendered as two cards on one page (both are tryvio-card-<handle>) —
one impression, not two.
⚠️ Reading impression history: rows written by widget versions older than the #354 release can contain those duplicates for stores on re-rendering themes, so a CTR trend that crosses that release compares two different denominators.
Segmented CTR query. 05_tasks/proof/304/segmented-ctr.sql — per-device / per-button-variant
CTR; measured on prod Icedout over 14 days: cold 5.5s / warm 209ms via
analytics_events_store_type_occurred_idx (no new index needed).
Dashboard semantics
The dashboard should show:
- Store-wide orders and revenue from the Shopify Orders API; store sessions and baseline conversion from Tryvio’s own web pixel (
product_viewedevents), which requires no additional Shopify scope or approval. - Tryvio attributed revenue from
attributed_order_lineswhen line-level rows exist. - Tryvio purchases as unique attributed
order_idcount, not raw line count. - Product table revenue as the sum of attributed line values for that product.
- Conversion lift only when sample-size guards pass; otherwise show a collecting-data state.
- ROI and ROAS only when cost inputs exist. Missing billing cost must be unavailable, not zero.
Overview card definitions:
| Card | Source | Definition |
|---|---|---|
| Button taps | analytics_events | Count of tryon_widget_click events in the selected range |
| Try-ons | tryon_billing_ledger (fallback: analytics_events) | Generated looks — the billing unit. Every generated look counts, so a session with 5 looks = 5 try-ons. Billable count from tryon_billing_ledger for the range when ledger coverage exists; otherwise COUNT(DISTINCT provider_job_id) over tryon_generation_succeeded events (also deduplicates the pre-2026-07-10 double-fired events, see Success finalize race). Replaces the old session-based “Try-ons” card and the removed “Completed try-ons” card. |
| Shoppers | analytics_events | Unique engaged Tryvio shoppers from tryon_* events, excluding passive impressions and plain product views |
| Try-on sessions (funnel only) | analytics_events | Distinct sessions with ≥1 succeeded generation (tryon_generation_succeeded, distinct tryon_session_id). Session-based counting now lives only in the funnel and conversion metrics — a funnel measures people through steps, so sessions are the correct unit there. Not shown as an Overview card; the funnel subtitle states this explicitly. |
| Tryvio attributed revenue | attributed_order_lines | Sum of attributed purchased line values that matched tried-product proof |
| Tryvio cost | Billing state + billing_events | Prorated subscription fee plus usage charges for the selected range, in the subscription’s own currency (see Billing cost semantics) |
| ROAS | valueMetrics | Tryvio attributed revenue / Tryvio cost — carries currencyMismatch when the sell and billing currencies differ with no FX rate |
| ROI | valueMetrics | (Tryvio attributed revenue - Tryvio cost) / Tryvio cost — same currency annotation |
| Store sessions | Tryvio web pixel | Count of distinct product_viewed events; no read_reports scope or Protected Customer Data approval required |
| Store conversion rate | Tryvio web pixel | Share of pixel sessions that resulted in a purchase |
| Estimated lift | Tryvio web pixel | Tryvio shopper conversion rate minus the non-Tryvio baseline cohort conversion rate; shown only when sample-size guard passes |
Widget coverage (#315, 2026-08-13)
Coverage = of the (product × day) pairs shoppers actually opened, the share that also had a try-on impression that day. Shown on the merchant’s Overview (the default landing) under “Reach”, above Conversion impact.
Do not compute it as impressions ÷ product_views. That ratio is what the issue was written
from, and measured on prod it returns 125% for Icedout, 573% for rqgjg0-cr and 1617% for
hxdgbj-2i — because the two sides count different surfaces. product_viewed comes from the web
pixel and fires once per product page; tryon_widget_impression fires for every collection card
too, so one collection page with 20 cards mints 20 impressions against zero product views. Counting
over viewed (product × day) pairs makes the numerator a subset of the denominator, so the result is
a percentage by construction.
| RPC | widget_coverage_stats(p_store_id, p_from, p_to) — 20260813b_widget_coverage_stats.sql |
| Verdict logic | src/lib/widget/coverage.ts (sample floor, cause, collection state) — unit-tested per AC |
| Wiring | 4th member of the /api/shopify/metrics Promise.all; inherits its unstable_cache (60s) + in-flight coalescing |
| Latency | measured on prod’s heaviest store: 7d 59ms · 14d 106ms · 30d 228ms warm (a cold first run pays ~1.9s in buffers) |
| Sample floor | MIN_VIEWED_PRODUCT_DAYS = 20 — below it the card says “not enough traffic yet” and prints no percentage |
| Healthy bar | HEALTHY_COVERAGE_PCT = 80 — at or above it no cause is shown |
Causes are only ever named when the data proves them. The issue guessed “page types disabled”
and “products with try-on off”; on prod every store with a store_widget_config row has product
enabled, and disabled-render impressions are 48 of 101,118 at the biggest store. The real
explanation is products that are in the catalogue, active, opened by shoppers, and never showed
a button (gyj58h-dv: 197 of 322). Order: widget_off → never_rendered (zero impressions anywhere,
which is the only way we can observe a Theme-Editor embed toggle we cannot read) → placement_gap →
inactive_products.
Collection reach is never folded into the ratio. It is reported on its own line from
ctx.page = 'collection' (#304). Because old cached widgets send no ctx at all for weeks, a store
with cards on and zero ctx reports “not measured yet”, never “no reach”.
Fail-soft: a coverage failure returns coverage: null and the card degrades alone. Migrations do
not deploy with code, so before widget_coverage_stats is applied the RPC does not exist — and
#316 is the story of exactly that throwing inside this same Promise.all and 500ing every merchant
screen. Regression test: metrics/route.test.ts — “a broken coverage read degrades the CARD”.
~1.9% of product_viewed rows carry no resolved product_id and are excluded from the denominator.
Do not count passive visitors as Tryvio shoppers. A visitor who only loaded a product page or saw a widget impression is not an engaged Tryvio shopper until they trigger a Tryvio event such as click, upload, generation, result view, add-to-cart, checkout, or attributed purchase.
SQL aggregation (2026-07-10, v2 2026-07-27)
All event-derived Overview/funnel/proof metrics (button taps, try-ons, shoppers, funnel steps,
step rates, top-product event columns, baseline cohorts) are computed by the Postgres RPC
merchant_metrics_event_totals_v3(p_store_id, p_from, p_to)
(apps/web/supabase/migrations/20260728b_metrics_v3_exclude_impressions.sql), called once for
the current range and once for the comparison range. The RPC aggregates over the full date
range in SQL — counts, distinct sessions, and distinct shoppers per event type, plus baseline
cohorts — and returns totals, not raw rows.
v2 (issue #128) is a single-pass rewrite of the original
merchant_metrics_event_totals (20260710_merchant_metrics_sql_aggregation.sql): the v1 shape ran
~15 sort-based count(DISTINCT) aggregates plus EXCEPT cohort chains over the raw event range, and
on a 346k-events/month store every sort spilled to disk at the cluster’s small work_mem — cold-cache
runs exceeded the 2-minute statement timeout daily (“Failed to load merchant metrics”). v2 replaces
every DISTINCT sort with hash GROUP BYs over narrow keys (per-session bool_or flags, per-cohort
flags, per-(product, session) inner grouping) fed by one materialized narrow scan, sets
work_mem = '96MB' inline, and revokes EXECUTE from PUBLIC/anon/authenticated
(service_role only — v1 had been PUBLIC-executable). The output contract is unchanged: same
signature, same 22-key jsonb, parity-gated byte-identical against v1 across every active store and
range before the swap.
v1 retirement (#137). Since 2026-07-28 the app calls v3 only; v1 has no caller left in the
repo. 20260730c_metrics_v1_retire.sql revokes the remaining grants on
merchant_metrics_event_totals and drops it — v1 was the last metrics RPC still executable by
PUBLIC/anon. The migration is staged, not applied: the bar is ≥1 week of stability on the
successor (v3 live 2026-07-28 ⇒ earliest 2026-08-04), applied under the db-migrate lock with the
owner’s go. The v1 migration file stays for history. v2 stays in the database on purpose — it is
v3’s rollback path (point getMerchantEventTotals back at v2); drop it in a later migration once v3
has had its own stability window.
Ingest clamp (#137). /api/events is public and unauthenticated, so a client-supplied
occurredAt is clamped into [now−48h, now+5min] at the ingest boundary
(lib/analytics/occurred-at.ts) before anything is written — a garbage or spoofed timestamp can no
longer land outside (or drag) a merchant range window. The clamp runs once per request and the
clamped value feeds the stored event, the derived events, tried_products and the 30-day
attribution window. Events with no client timestamp are untouched (server clock), and server-emitted
events bypass the clamp entirely because they call recordAnalyticsEvent directly.
Health alert (#137). computeHealthReport carries a merchant_metrics component: over the last
30 minutes of app_logs for route shopify/metrics it counts request.error rows and computes the
p95 of request.success durationMs. ≥1 error, or p95 > 8 s (the statement-timeout budget) over ≥5
served requests, marks the component degraded — which degrades the whole report, so
/api/health/deep returns 503 and the existing 10-minute watchdog emails the owner. No new alert
channel; thresholds are constants in lib/server/health.ts.
Before this fix, event metrics were fetched with listRecentAnalyticsEvents() (max 50,000 newest
analytics_events rows) and aggregated in JS. On busy stores this silently truncated the range:
Icedout’s 30-day window held 183,000+ rows, so the 50k cutoff covered only the newest ~5 days —
every event-derived metric (impressions, clicks, try-ons, shoppers, funnel steps and rates,
baseline cohorts, comparison deltas) quietly reported numbers for a fraction of the requested
range. listRecentAnalyticsEvents has been removed from all metrics computation; it is no longer
called anywhere in the merchant-metrics path. Proof-drawer event chains still use a targeted,
bounded per-session fetch (not the removed bulk fetch).
The merchant Overview includes a Tryvio ROI panel with:
- Tryvio attributed revenue: strict product-level attributed line revenue.
- Tryvio cost: prorated subscription cost for the selected date range plus known usage charges.
- ROAS: Tryvio attributed revenue divided by Tryvio cost.
- ROI:
(Tryvio attributed revenue - Tryvio cost) / Tryvio cost. - Estimated lift: Tryvio shopper purchase conversion vs. the non-Tryvio baseline cohort (
product_viewedbut no Tryvio interaction); shown only after the sample-size guard passes;Collecting datawhen either cohort is too small,Not enough baseline traffic yetwhen the baseline cohort is empty.
Each value card exposes tooltip copy explaining the formula and limitation. If billing inputs are
unavailable, the state must render as unavailable or collecting data, never as a misleading 0.
Pixel-based session data (store conversion, baseline) does not depend on Shopify report scopes and
is available on any plan as long as the web pixel is installed.
View proof opens attributed line rows without shopper PII. Rows include order id/name, line value,
product identifiers, try-on session id, role/source, attribution mode/source, tried timestamp, order
timestamp, and the available event chain timestamps from widget click through checkout/order.
Activation ladder (operator, #306)
A store is ACTIVATED when it has ≥1 tryon_sessions row with status='completed' and
source_channel='shopify_storefront'. Latched and permanent — a store that later goes quiet
stays activated (quiet stores are the widget-health watchdog’s question, not this one).
Surfaced on /admin → Activation (/api/admin/activation) and in the Monday ops-digest.
Both read one bounded RPC, activation_ladder_stats(), and all judgement lives in
lib/activation/activation-ladder.ts — one definition, two surfaces.
Why session-truth and not the success event. tryon_generation_succeeded is a shopper-side
beacon: it does not survive an uninstall and it over-counts live stores (one session can deliver
several looks). Measured on prod 2026-08-12, the event definition reported 8 activated stores and
session-truth reported 9 — the missing one was our only churned paying merchant, which kept 6
analytics events but 1 650 sessions and 1 367 billed try-ons. Full rationale and the other rejected
candidates: 05_tasks/specs/activation-measurement.md.
Unit warning. The ladder counts completed sessions; the merchant-facing Try-ons card counts looks (the billing unit, see the table above). One session can deliver several looks, so the same store reads lower here. This is deliberate: the ladder is an instrument about stores.
| Rung | Reached when |
|---|---|
| Installed | always |
| Synced products | product_total > 0 or activated |
| Widget rendered | impressions > 0 or activated |
| First try-on | ≥1 completed storefront session |
| 10th try-on | ≥10 completed sessions |
| Paying | live install and subscription_status='active' |
Rungs are counted per predicate with those implications — a delivered try-on proves the store had
products and a rendered widget even after an uninstall wipes its catalogue. Paying is not
gated on the 10th try-on (a merchant may subscribe after one), so a rung can exceed the one above
it; the UI draws that inversion in the warning colour instead of hiding it.
Stores that never activated carry one stall reason, earliest failure first: never_synced,
no_traffic, widget_dark (shoppers on the PDP, zero impressions), rendered_not_clicked,
upload_abandoned, generation_failing. The last two split the same step deliberately: on prod
every stalled store has zero failed sessions and only created ones, so “clicked but no image”
is abandonment, not a defect — a real failure outranks the abandonment reading whenever it exists.
⚠️ activation_ladder_stats() is a migration, and migrations do not deploy. Apply
20260812_activation_ladder_stats.sql, then notify pgrst, 'reload schema';, then run
schema-drift.mjs, before this code reaches an environment. Until then the tab 500s and the digest
falls back to one honest line.
Billing cost semantics
ROI cost is calculated for the selected dashboard range:
tryvioCost = proratedPlanFee + usageChargesForRange
roas = tryvioAttributedRevenue / tryvioCost
roi = (tryvioAttributedRevenue - tryvioCost) / tryvioCostFor standard plans, the monthly fee comes from the plan catalog. For custom plans, it comes from the
store’s custom billing cache, which is reconciled from Shopify. If a restored custom plan has
current_period_starts_at but no current_period_ends_at, the metrics route uses a 30-day fallback
period so cost is still available.
Usage charges for range (fixed 2026-07-10)
getUsageChargesForRange (api/shopify/metrics/route.ts) now sources the usage component of
tryvioCost from tryon_billing_ledger — overage rows (was_overage = true, status = 'billable') created within the selected range, × getOverageRate(store), for all plans
(standard and custom). If the requested range starts before the store’s earliest ledger row
(coverage rule), it falls back to summing settled overage_charged billing_events for that
range instead.
Previously, for custom-0 plans, cost was analytics successfulGenerations × rate —
successfulGenerations is a session count, not a billable-try-on count, so this overstated or
understated cost depending on retries/refunds. This produced Icedout’s $139.86 vs. a real $381
overage charge. Analytics (analytics_events) are no longer used for cost anywhere; ROAS and ROI
inherit the corrected cost automatically since they’re derived from tryvioCost.
Custom plans with custom_try_on_limit = 0 mean zero included try-ons, not a hard usage limit.
The dashboard must describe this as billable usage, for example 32 billable try-ons used this period, not 32 / 0.
Plan fee currency (fixed 2026-07-31, #66)
proratedPlanFee is resolved by lib/billing/plan-fee.ts → live Shopify recurring price (source
of truth, from getShopifySubscriptionPricing, fetched inside the metrics route’s Promise.all) →
the fixed per-currency table (getPlanPricing, the numbers the subscription was created with) → the
USD list price. Before this, the fee was always the USD list price while the card was labelled with
the subscription’s currency, so a EUR-billed Starter read €39.99 against a €36.99 invoice and that
inflated cost fed ROAS/ROI.
Known and deliberately unchanged: the settlement path still charges getOverageRate(store) (the USD
number) into a possibly-EUR subscription. The cost card matches what is actually charged; correcting
the charged rate is a live-money decision with its own issue.
Currency of the ratios (#66 AC3)
Revenue is in the store’s sell currency; cost is in the subscription currency. computeRoas /
computeRoi take a RatioCurrencyContext:
- same currency (the normal case once the fee is localized) → plain division;
- different currency with
fxRate→ revenue converted into the cost currency,fxRateechoed on the metric; - different currency without a rate → the value is still returned but carries
currencyMismatch, which the dashboard renders asApprox. — revenue in X, cost in Y, not FX-converted.
There is no FX rate source wired yet (owner decision pending); passing currencies.fxRate in
api/shopify/metrics/route.ts is the only wiring needed once one exists.
Try-ons headline vs the Top-products table (fixed 2026-07-31, #39)
The Try-ons card and the per-product table are different scopes and used to disagree with no explanation (Icedout, 30d: card 8,647 vs a table summing 4,396). Three fixes:
getMerchantMetricsreturns an additivetryOnLooksBreakdown—looksTotal,looksInTopProducts,looksInOtherProducts,looksNotMatchedToProduct,productsWithLooks,topProductCount— built from the SAME ranking that producestopProducts(TOP_PRODUCTS_LIMIT= 10). Invariant: the three look buckets sum tolooksTotal(the unmatched bucket floors at 0 for bundles, where one provider job counts once per product it dressed).- The card prints which source it used (
billing ledgervsanalytics), becausebillableTryOnsisnullfor ranges predating ledger coverage and the source used to flip silently as the date range moved. - The per-product Try-ons column is looks only — the old
tryOnLooks ?? tryOnSessionsfallback could render a session count inside a looks column.
Copy lives in lib/client/merchant-metrics-labels.ts; the pure buckets in
lib/analytics/looks-breakdown.ts.
Widget and pixel contract
The storefront widget writes a 30-day local proof history under tryvio_attribution_history_v1 and
adds hidden _tryvio_* line properties when Tryvio adds an item to cart. These are additive signals.
The durable source of truth is still server-side proof in Supabase.
The web pixel must only attach a Tryvio session to add-to-cart or checkout events when the purchased line’s product matches a proof by handle or external product id. Missing product identity must not credit Tryvio.
/api/events normalizes both Tryvio’s flat per-line checkout payload and nested Shopify checkout
payloads before attribution. For multi-line payloads, only lines matching tried-product proof are
credited; the legacy same-session fallback is limited to a single-line payload to avoid overclaiming.
Orders reconciliation
POST /api/shopify/attribution/reconcile runs an on-demand Shopify Orders reconciliation for the
selected range, clamped to the v1 60-day window. It fetches recent Shopify orders, inspects
Tryvio line/order attributes, matches line items against tried-product proof where available, and
upserts attributed rows with source orders_reconciliation. This is the recovery path when the
pixel missed checkout_completed.
2026-07-01 icedout incident
Symptoms on zmwqzs-ir.myshopify.com:
- Custom range 2026-06-30 to 2026-07-01 showed
1button tap,0try-ons, and about124shoppers. - Changing to 2026-07-01 or last 7 days kept nearly the same shopper count.
- Billing showed
32 / 0 try-ons used this period. - ROI was not visible.
Root causes:
- ROI UI/API work had not been shipped to production before the owner checked the Shopify Admin dashboard.
- The dashboard read only the first Supabase REST page of
analytics_events, truncating high-volume date ranges. shopperscounted passive visitors/impressions instead of engaged Tryvio shoppers.- Custom plan copy rendered
used / limiteven when0meant zero included billable try-ons. - Restored custom plans with missing
current_period_ends_atmade cost/ROI unavailable.
Expected after the fix logic for that store:
- 2026-06-30 to 2026-07-01: about
77button taps,43try-ons,50engaged Tryvio shoppers, and40completed/result-viewed try-ons. - 2026-07-01 only: about
1button tap,0try-ons, and1engaged Tryvio shopper. - Custom plan usage copy:
32 billable try-ons used this period.
Historical record — try-ons and completed/result-viewed try-ons above use the pre-2026-07-10
session-based definitions. As of 2026-07-10, Try-ons = generated looks (the billing unit) and
the “Completed try-ons” card no longer exists; see SQL aggregation
(2026-07-10) and the Overview card definitions
table above for the current definitions.
Verification commands for this release were full vitest, tsc, and production pnpm build.