airbridge-mixpanel-explainer

v2v3 · +222 −21 · View latest

⋯ 591 unchanged lines
    flex-wrap: wrap;
  }
  /* ---------- touch-semantics session player ---------- */
  .session-btn {
    border: 1px solid var(--border-2);
    background: var(--panel-2);
    color: var(--ink);
    border-radius: 4px;
    padding: 8px 12px;
    cursor: pointer;
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    font-size: 0.86rem;
    text-align: left;
    line-height: 1.4;
  }
  .session-btn .n {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.68rem; color: var(--accent); display: block; margin-bottom: 2px;
  }
  .session-btn.active { border-color: var(--accent); background: var(--accent-soft); }
  .session-btn:hover { border-color: var(--accent); }
  .state-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
  .state-table th, .state-table td { text-align: left; padding: 8px 12px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
  .state-table th { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 0.68rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); }
  .state-table td.field { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-weight: 600; white-space: nowrap; }
  .state-table td.val { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
  .status-pill {
    font-size: 0.7rem; font-weight: 600; padding: 2px 8px; border-radius: 10px; white-space: nowrap;
  }
  .status-pill.changed { color: var(--accent-strong); background: var(--accent-soft); }
  .status-pill.unchanged { color: var(--muted); background: var(--panel-2); }
  .status-pill.carried { color: var(--warn); background: var(--warn-soft); }
  footer.provenance {
    max-width: 920px;
    margin: 0 auto;
⋯ 9 unchanged lines
<header class="masthead">
  <div class="masthead-inner">
    <p class="kicker">Developer guide · v1.1 (Chief PM review) · Interactive</p>
    <p class="kicker">Developer guide · v1.2 · Interactive</p>
    <h1 class="title">AirBridge → Mixpanel Instrumentation Mapping</h1>
    <div class="meta-row">
      <span class="badge">Owner <b>Sourabh Mishra</b> (MMRP)</span>
⋯ 6 unchanged lines
      <span class="badge">Staging <b>4017466</b></span>
      <span class="badge">Dev <b>4002103</b></span>
      <span class="badge warn">Prod is behind — see §Naming</span>
      <span class="badge warn">v1.1 corrected 3 contract drifts — see below</span>
      <span class="badge warn">v1.2 adds marketing attribution (§3b) — see below</span>
    </div>
  </div>
</header>
<nav class="jumpnav" aria-label="Section navigation">
  <div class="jumpnav-inner">
    <a href="#whatchanged">v1.1 changes</a>
    <a href="#whatchanged">What changed</a>
    <a href="#tryit">Try it</a>
    <a href="#touchsemantics">Touch semantics</a>
    <a href="#chain">The chain</a>
    <a href="#timing">Timing rules</a>
    <a href="#code">Code</a>
⋯ 8 unchanged lines
<main>
  <section id="whatchanged">
    <p class="section-label">Chief PM review — verified against committed enums v1.3.1</p>
    <h2>What changed in v1.1</h2>
    <p class="lede">v1.0 asserted things about the committed enums file that turned out not to be true. This version is corrected against the actual file, not asserted.</p>
    <p class="section-label">Chief PM review (v1.1) + marketing attribution (v1.2)</p>
    <h2>What changed</h2>
    <p class="lede">Two revisions today. v1.1 corrected claims that turned out to be wrong against the committed enums file. v1.2 is additive: a whole new attribution contract for non-affiliate marketing links that didn't exist before.</p>
    <h3 style="margin-top:4px;">v1.2 — marketing attribution added (new, not a correction)</h3>
    <div class="pin-list">
      <div class="pin">
        <p><strong>A third load-bearing output.</strong> Alongside <code>acquisition_source</code> and <code>marketing_affiliate_id</code>, every session must now carry a full set of <code>mx_*</code> campaign-context super props — <code>mx_channel</code>, <code>mx_phase</code>, <code>mx_episode</code>, <code>mx_recipe</code>, <code>mx_asset</code>, <code>mx_placement</code>, <code>mx_aff</code>. This is what feeds the Channels tab, the Saturday readout, and per-creative analysis. Previously about half the <code>param_map</code> was unmapped to anything.</p>
      </div>
      <div class="pin">
        <p><strong>Touch semantics — the new hard part.</strong> Profile fields (<code>acquisition_source</code>, <code>marketing_affiliate_id</code>) stay first-touch immutable, but the <code>mx_*</code> super props are <strong>latest-touch</strong> — overwritten on every new tagged open, left alone on organic opens. Getting this backwards (e.g. <code>$set_once</code>-ing the super props, or clearing them on organic opens) silently breaks channel/creative attribution. See the interactive walkthrough below.</p>
      </div>
      <div class="pin">
        <p><strong>A second verify test</strong> (§8) specifically for marketing links, including a "tap a second link, different channel" step to confirm the overwrite behavior actually happens.</p>
      </div>
    </div>
    <h3 style="margin-top:20px;">v1.1 — corrections against the committed enums file</h3>
    <div class="pin-list">
      <div class="pin">
        <p><strong>Fixed a fabricated claim.</strong> v1.0 said the <code>acquisition_source</code> rollup "lives in <code>mixxi_attribution_enums.json</code>." It doesn't — no such group exists in the committed file. Now an explicit open pin with a safe interim (write the raw <code>channel</code> value).</p>
      </div>
      <div class="pin">
⋯ 57 unchanged lines
    </div>
  </section>
  <section id="touchsemantics">
    <p class="section-label">§3b — new in v1.2, "read carefully"</p>
    <h2>Touch semantics: profile vs. super props</h2>
    <p class="lede">The rule that's easy to get backwards: profile fields never change after first touch, but the <code>mx_*</code> super props update on every new tagged open — and organic opens don't clear them. Step through three sessions for the same user to see it happen.</p>
    <div class="try-panel">
      <div class="try-header" style="justify-content:flex-start; gap:16px;">
        <h3 style="margin:0;">Play through: one user, three sessions</h3>
      </div>
      <div class="link-strip" style="padding-bottom:16px;">
        <div class="chips" id="session-buttons" style="gap:8px;"></div>
      </div>
      <div class="detail" id="session-detail" style="padding-top:8px;"></div>
    </div>
  </section>
  <section id="chain">
    <p class="section-label">§1</p>
    <h2>The chain — what happens on a click</h2>
⋯ 97 unchanged lines
airbridge.onDeeplink(params -&gt; applyAttribution(params));
function applyAttribution(params) {
    <span class="c">// 5. Map link params → Mixpanel.</span>
    mixpanel.people.setOnce({ acquisition_source: rollup(params.channel) });  <span class="c">// first-touch, immutable</span>
    <span class="c">// 5a. PROFILE — first-touch, immutable (never overwrite).</span>
    mixpanel.people.setOnce({ acquisition_source: rollup(params.channel) });  <span class="c">// see §3 Pin #0 — raw channel until rollup ships</span>
    mixpanel.people.set({ airbridge_id: airbridge.getAirbridgeId() });
    <span class="c">// affiliate links only — code carried in `campaign`, matches deep-link path</span>
    if (isAffiliate(params)) {
        mixpanel.people.set({ marketing_affiliate_id: params.campaign });
        mixpanel.register({ marketing_affiliate_id: params.campaign });        <span class="c">// super prop → rides on events</span>
        mixpanel.people.set({ marketing_affiliate_id: params.campaign });     <span class="c">// first attributed touch</span>
    }
    mixpanel.register({ acquisition_source: rollup(params.channel) });
    <span class="c">// 5b. MARKETING CONTEXT — latest-touch super props (§3b). OVERWRITE on every</span>
    <span class="c">// attributed open; ride automatically on all subsequent events.</span>
    mixpanel.register({
        acquisition_source: rollup(params.channel),
        mx_channel:   params.channel,
        mx_phase:     splitCampaign(params.campaign).phase,      <span class="c">// marketing links: phase__episode</span>
        mx_episode:   splitCampaign(params.campaign).episode,
        mx_recipe:    splitCreative(params.ad_creative).recipe,
        mx_asset:     splitCreative(params.ad_creative).asset,
        mx_placement: params.content,
        mx_funding:   params.sub_param_funding,                  <span class="c">// organic | sponsorship_funded</span>
        mx_ad_platform: params.sub_param_ad_platform,            <span class="c">// paid only; omit when absent/none</span>
        mx_persona:   params.sub_param_persona,
        mx_layer:     params.sub_param_layer,
        mx_team:      params.sub_param_team,
        mx_cta:       params.sub_param_cta,
        ...(isAffiliate(params) && { marketing_affiliate_id: params.campaign, mx_aff: params.campaign })
    });  <span class="c">// omit any absent param entirely — no empty strings</span>
}
<span class="c">// 6. Emit funnel events with join props attached (super props ride automatically).</span>
⋯ 52 unchanged lines
      <h3>Success criteria</h3>
      <p>The same affiliate code is visible on both sides (AirBridge postback <em>and</em> Mixpanel <code>marketing_affiliate_id</code>) and MMRP joins them. If the code is missing on the Mixpanel side, the break is app-side (deferred link not read, or props set before Mixpanel init). If present in Mixpanel but not joined, the break is the postback field mapping (see Open Pins).</p>
    </div>
    <h3 style="margin-top:26px;">Second decisive test — a marketing link (new in v1.2, do this too)</h3>
    <ol class="steps">
      <li>Cut a <em>marketing</em> test link: channel <code>instagram</code>, campaign <code>phase1_tease__ep01_discovery</code>, creative <code>trend__testasset_01</code>, <code>sub_param_funding=organic</code>.</li>
      <li>Clean device → tap → install → consent → sign up → then trigger one more event (e.g. open an episode).</li>
      <li>In Mixpanel Live View confirm: <code>signup_complete</code> AND the later event <strong>both</strong> carry <code>mx_channel=instagram</code>, <code>mx_phase=phase1_tease</code>, <code>mx_episode=ep01_discovery</code>, <code>mx_recipe=trend</code>, <code>mx_asset=testasset_01</code>, <code>mx_funding=organic</code> — and <strong>no</strong> <code>marketing_affiliate_id</code> (it's not an affiliate link).</li>
      <li>Latest-touch check: on the same device, tap a <em>second</em> marketing link with a different channel (e.g. <code>whatsapp</code>) → open → trigger an event → confirm the event's <code>mx_channel</code> is now <code>whatsapp</code> while the profile's <code>acquisition_source</code> is unchanged from the first link.</li>
    </ol>
    <div class="card" style="margin-top:18px;">
      <h3>Success criteria</h3>
      <p>Funnel events carry the full <code>mx_*</code> context matching the link that drove the session; the profile stays first-touch. If events show no <code>mx_*</code> props, the super-prop registration isn't firing. If they show the <em>old</em> campaign after a new tagged open, the overwrite semantics are wrong (§3b — see the Touch semantics walkthrough above).</p>
    </div>
  </section>
  <section id="pins">
⋯ 26 unchanged lines
</main>
<footer class="provenance">
  Source: AirBridge → Mixpanel Mapping Guide v1.1 (Chief PM review) · 22 Jul 2026 · Ingestion &amp; Experimentation, MMRP · route Sourabh → Kapil → Prajwalit · single sink Mixpanel 4017469 · Confidential — Celerity Studios.
  Full source document stored in the Celerity org brain at <code>meetings/inbox/2026-07-22-airbridge-mixpanel-mapping-guide.md</code> (<code>status: proposed</code>), updated in place from v1.0 to v1.1.
  Source: AirBridge → Mixpanel Mapping Guide v1.2 · 22 Jul 2026 · Ingestion &amp; Experimentation, MMRP · route Sourabh → Kapil → Prajwalit · single sink Mixpanel 4017469 · Confidential — Celerity Studios.
  Full source document stored in the Celerity org brain at <code>meetings/inbox/2026-07-22-airbridge-mixpanel-mapping-guide.md</code> (<code>status: proposed</code>), updated in place v1.0 → v1.1 → v1.2.
</footer>
<script>
⋯ 24 unchanged lines
      setWhere: "event prop · paid links only",
      rule: "New in v1.1 — was missing from v1.0 entirely. Present only when funding=sponsorship_funded; omit for organic links. Toggle “Funding” above to see it appear/disappear."
    },
    ad_creative: {
      example: "trend__testasset_01",
      target: "mx_recipe + mx_asset",
      setWhere: "super prop · optional",
      rule: "New in v1.2 (§3b). Split on the first \"__\": part 1 → mx_recipe (\"trend\"), part 2 → mx_asset (\"testasset_01\"). Registered as a super prop — rides on every subsequent event in the session, not just the attributing one."
    },
    content: {
      example: "reel",
      target: "mx_placement",
      setWhere: "super prop · optional",
      rule: "New in v1.2 (§3b). The surface/unit the ad ran in — carried straight through as mx_placement."
    },
    sub_param_layer: {
      example: "deep_link",
      target: "mx_layer",
⋯ 22 unchanged lines
    affiliate: {
      campaign: {
        example: "RISING-E0-A7K2P9",
        target: "marketing_affiliate_id",
        setWhere: "profile + funnel events",
        rule: "The affiliate code lives in campaign (per MAP runbook). This value must equal the deep-link path code exactly — a mismatch silently misattributes."
        target: "marketing_affiliate_id + mx_aff",
        setWhere: "profile (first-touch) + super prop (latest-touch)",
        rule: "The affiliate code lives in campaign (per MAP runbook) and must equal the deep-link path code exactly — a mismatch silently misattributes. v1.2: it's also copied onto the mx_aff super prop, so it rides on session events the same way marketing context does — the profile copy is still first-touch immutable."
      },
      deeplink: {
        label: "deep link path",
⋯ 6 unchanged lines
    },
    marketing: {
      campaign: {
        example: "phase1_tease__pre_discovery",
        target: "(not an affiliate id)",
        setWhere: "event prop · optional",
        rule: "Marketing campaigns are not affiliates — do NOT write a marketing_affiliate_id for these.",
        example: "phase1_tease__ep01_discovery",
        target: "mx_phase + mx_episode",
        setWhere: "super prop · latest-touch",
        rule: "v1.2 (§3b): marketing campaigns split on the first \"__\" — part 1 → mx_phase (\"phase1_tease\"), part 2 → mx_episode (\"ep01_discovery\"). Still not an affiliate id — do NOT write marketing_affiliate_id for these. Overwritten on every new tagged open (latest-touch), unlike the first-touch profile fields.",
        neutral: true
      },
      deeplink: {
⋯ 64 unchanged lines
    chipRow.innerHTML = "";
    chipRow.appendChild(makeChip("channel"));
    chipRow.appendChild(makeChip("campaign"));
    ["sub_param_persona", "sub_param_funding", "sub_param_ad_platform", "sub_param_layer", "sub_param_team", "sub_param_cta"].forEach(function (k) {
    ["sub_param_persona", "sub_param_funding", "sub_param_ad_platform", "sub_param_layer", "sub_param_team", "sub_param_cta", "ad_creative", "content"].forEach(function (k) {
      chipRow.appendChild(makeChip(k));
    });
    chipRow.appendChild(makeChip("og_tag_id"));
⋯ 64 unchanged lines
  renderChips();
  renderDetail();
  // ---------- touch-semantics session player ----------
  var SESSIONS = [
    {
      label: "Session 1 — Instagram, week 1",
      sub: "channel=instagram, campaign=phase1_tease__ep01_discovery",
      isTaggedOpen: true,
      acquisition_source: "instagram",
      marketing_affiliate_id: null,
      mx_channel: "instagram",
      mx_phase: "phase1_tease",
      mx_episode: "ep01_discovery"
    },
    {
      label: "Session 2 — WhatsApp share tap, week 3",
      sub: "channel=whatsapp, campaign=phase3_share__ep03_finale",
      isTaggedOpen: true,
      acquisition_source: "instagram",
      marketing_affiliate_id: null,
      mx_channel: "whatsapp",
      mx_phase: "phase3_share",
      mx_episode: "ep03_finale"
    },
    {
      label: "Session 3 — Organic open, week 4",
      sub: "no link tapped — plain app open",
      isTaggedOpen: false,
      acquisition_source: "instagram",
      marketing_affiliate_id: null,
      mx_channel: "whatsapp",
      mx_phase: "phase3_share",
      mx_episode: "ep03_finale"
    }
  ];
  var sessionState = { upTo: 0 };
  var sessionButtonsEl = document.getElementById("session-buttons");
  var sessionDetailEl = document.getElementById("session-detail");
  function renderSessionButtons() {
    sessionButtonsEl.innerHTML = "";
    SESSIONS.forEach(function (s, i) {
      var btn = document.createElement("button");
      btn.type = "button";
      btn.className = "session-btn" + (i === sessionState.upTo ? " active" : "");
      btn.innerHTML = '<span class="n">' + s.label + '</span>' + s.sub;
      btn.addEventListener("click", function () { sessionState.upTo = i; renderSessionButtons(); renderSessionDetail(); });
      sessionButtonsEl.appendChild(btn);
    });
  }
  function renderSessionDetail() {
    var i = sessionState.upTo;
    var current = SESSIONS[i];
    var prev = i > 0 ? SESSIONS[i - 1] : null;
    function row(field, value, kind) {
      var pill = kind === "changed"
        ? '<span class="status-pill changed">just written ($set_once — first time)</span>'
        : kind === "overwritten"
        ? '<span class="status-pill changed">overwritten — latest touch</span>'
        : kind === "carried"
        ? '<span class="status-pill carried">carried over — organic open didn\'t clear it</span>'
        : '<span class="status-pill unchanged">unchanged — first-touch immutable</span>';
      return '<tr><td class="field">' + field + '</td><td class="val">' + (value === null ? '<span class="detail-hint">null</span>' : value) + '</td><td>' + pill + '</td></tr>';
    }
    var rows = "";
    // profile fields — always first-touch, only ever "just written" on session 0, else unchanged
    rows += row("acquisition_source", current.acquisition_source, i === 0 ? "changed" : "unchanged");
    rows += row("marketing_affiliate_id", current.marketing_affiliate_id, i === 0 ? "changed" : "unchanged");
    ["mx_channel", "mx_phase", "mx_episode"].forEach(function (key) {
      var kind;
      if (!prev) kind = "changed";
      else if (!current.isTaggedOpen) kind = "carried";
      else if (current[key] !== prev[key]) kind = "overwritten";
      else kind = "unchanged";
      rows += row(key, current[key], kind);
    });
    sessionDetailEl.innerHTML =
      '<div class="table-wrap"><table class="state-table"><tr><th>Field</th><th>Value after this session</th><th>What just happened</th></tr>' + rows + '</table></div>' +
      '<p class="rule" style="margin-top:14px;">' + (
        i === 0
          ? "First attributed touch: everything gets written for the first time."
          : i === 1
          ? "A new tagged link came in from a different channel. The profile's acquisition_source stays exactly as it was set on session 1 (first-touch, $set_once) — but the mx_* super props overwrite completely to reflect this session's actual campaign."
          : "No link this time — just an organic app open. Nothing gets cleared: the super props still carry session 2's context, because organic opens don't reset attribution. This is the behavior that's easy to get backwards."
      ) + '</p>';
  }
  renderSessionButtons();
  renderSessionDetail();
})();
</script>