feat(telemetry): install referrer attribution for growth channels (#3318)

Tracks whether installs came from Reddit, X, or organic by baking a
ref tag into the install command.

Growth bot shares:
  curl -fsSL ... | SPAWN_REF=reddit bash
  curl -fsSL ... | SPAWN_REF=x bash

install.sh: if SPAWN_REF is set, sanitizes it (alphanumeric + hyphens,
max 32 chars) and writes to ~/.config/spawn/.ref. Only written once —
never overwritten on updates.

index.ts: on startup, reads .ref and sets it as telemetry context via
setTelemetryContext("ref", ref). Every PostHog event (funnel, lifecycle,
errors) now carries ref=reddit or ref=x for attributed installs, or no
ref for organic.

PostHog query: filter any event by ref=reddit to see "how many Reddit-
sourced users made it through the funnel" vs organic.

Bumps 1.0.15 -> 1.0.16.

Co-authored-by: A <258483684+la14-1@users.noreply.github.com>
This commit is contained in:
Ahmed Abushagur 2026-04-18 00:59:22 -07:00 committed by GitHub
parent dc4fb59f67
commit 51e36d2154
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 33 additions and 0 deletions

View file

@ -2,6 +2,7 @@
import type { Manifest } from "./manifest.js";
import { readFileSync } from "node:fs";
import { getErrorMessage, isString, toRecord } from "@openrouter/spawn-shared";
import pc from "picocolors";
import pkg from "../package.json" with { type: "json" };
@ -38,6 +39,7 @@ import {
} from "./commands/index.js";
import { expandEqualsFlags, findUnknownFlag } from "./flags.js";
import { agentKeys, cloudKeys, getCacheAge, loadManifest } from "./manifest.js";
import { getInstallRefPath } from "./shared/paths.js";
import { asyncTryCatch, asyncTryCatchIf, isFileError, isNetworkError, tryCatch, tryCatchIf } from "./shared/result.js";
import { captureError, initTelemetry, setTelemetryContext } from "./shared/telemetry.js";
import { checkForUpdates } from "./update-check.js";
@ -48,6 +50,16 @@ const VERSION = pkg.version;
// Disabled with SPAWN_TELEMETRY=0.
initTelemetry(VERSION);
// Attribution: if the user installed via a tagged URL (SPAWN_REF=reddit|x|...),
// the install script persisted the ref to ~/.config/spawn/.ref. Read it once
// and attach to every telemetry event so PostHog can segment by acquisition channel.
tryCatchIf(isFileError, () => {
const ref = readFileSync(getInstallRefPath(), "utf8").trim();
if (ref && /^[a-zA-Z0-9_-]+$/.test(ref)) {
setTelemetryContext("ref", ref);
}
});
function handleError(err: unknown): never {
captureError("cli_error", err);
const msg = getErrorMessage(err);

View file

@ -58,6 +58,11 @@ export function getSpawnPreferencesPath(): string {
return join(getUserHome(), ".config", "spawn", "preferences.json");
}
/** Return the path to the install referrer file: ~/.config/spawn/.ref */
export function getInstallRefPath(): string {
return join(getUserHome(), ".config", "spawn", ".ref");
}
/** Return the cache directory for spawn, respecting XDG_CACHE_HOME. */
export function getCacheDir(): string {
return join(process.env.XDG_CACHE_HOME || join(getUserHome(), ".cache"), "spawn");

View file

@ -374,3 +374,19 @@ ensure_min_bun_version
log_step "Installing spawn via bun..."
build_and_install
# Persist install referrer (e.g. SPAWN_REF=reddit) so the CLI can report
# attribution on first run. Only written once — never overwritten on updates.
if [ -n "${SPAWN_REF:-}" ]; then
_ref_dir="${HOME}/.config/spawn"
_ref_file="${_ref_dir}/.ref"
if [ ! -f "${_ref_file}" ]; then
mkdir -p "${_ref_dir}"
# Sanitize: allow only alphanumeric, hyphens, underscores (no injection)
_clean_ref=$(printf '%s' "${SPAWN_REF}" | tr -cd 'a-zA-Z0-9_-' | head -c 32)
if [ -n "${_clean_ref}" ]; then
printf '%s' "${_clean_ref}" > "${_ref_file}"
log_info "Install referrer: ${_clean_ref}"
fi
fi
fi