The short answer
Product-led growth (PLG) means the product itself brings in and converts customers, with free or self-serve access as the front door. Telemetry is the data layer underneath: a stream of events saying who did what in the product. A GTM engineer owns it because the data sits between engineering, marketing and sales, and nobody else owns the join. Your job is to turn raw usage into a ranked list of accounts a human should talk to now.
Why this matters
The roadmap line says: "The product is your best salesperson. Hand-raisers convert 5x higher than cold outbound." Half of that is sound. The 5x number is folklore.
Where the 5x comes from. The nearest source is the 2021 Product-Led Sales Benchmark Report from Pocus and First Round Capital. Its introduction says sales should work "product-qualified opportunities that will convert 5x the rate of marketing qualified leads" (MQLs, leads that marketing qualified, for example by a form fill). That sentence has no source and no table behind it. The survey itself (200+ respondents, September to November 2021) counted behaviors, such as 62.3% saying a sales touch happens after a user raises a hand. It did not measure conversion. The PLG Handbook traces a "5-6x" figure to one practitioner (Kieran Flanagan, then of HubSpot), calls it a report and not a study, and says the multiple depends on how a company defines an MQL.
Three groups get mixed up. A hand-raiser asks for contact, for example by clicking "talk to sales". A product qualified lead (PQL) qualifies by behavior, whether or not they asked. The roadmap compares hand-raisers to cold outbound, but the reports compare PQLs to MQLs.
Numbers that do have a stated source:
- ProductLed's survey of 600+ SaaS businesses (no method details on the page): free trials that use PQLs convert to paid 25% of the time, against 9% of free accounts overall (ProductLed). That is about 2.8x, not 5x, and part of it is selection: PQLs already look likely to buy.
- Free-to-paid base rates, from 1,000+ B2B SaaS products (Kyle Poyar and Pendo, in Lenny's Newsletter, 1 August 2023): self-serve freemium (a free plan with no time limit) 3-5% is good and 6-8% great; free trials 8-12% good and 15-25% great. Developer-focused products had a median of 5%, half the rate of others (source, paywalled).
Verdict. The direction holds: real usage plus good fit beats a form fill. I found no study that supports 5x. The one survey with both numbers implies about 2.8x, and your own will differ. Measure it: hold out a random slice of PQLs that sales does not touch and compare.
Core concepts
PLG, sales-led and product-led sales
In a sales-led motion, sales and marketing find buyers from firmographics (company facts such as size and industry) and a rep makes first contact. In pure PLG, the user signs up alone and pays by card. In product-led sales (PLS), a user signs up alone and a rep steps in when usage data says so. Pocus defines PLS as relying on existing users to drive conversion, upsell, cross-sell and expansion. This motion needs a GTM engineer. Pure PLG is rare: in the Pocus and First Round survey 54.6% had both PLG and a sales team, and only 2.6% had no plans to add sales.
MQL, PQL and PQA
| Term | Qualified by | Unit | Typical signal |
|---|---|---|---|
| MQL (marketing qualified lead) | Marketing activity | A person | Downloaded an ebook |
| PQL (product qualified lead) | Product behavior plus fit | Person or account | Hit the aha moment at a company that matches your ICP |
| PQA (product qualified account) | Behavior across a team | An account | Several users each pass the PQL bar |
An ideal customer profile (ICP) describes the companies you most want, for example 200 or more employees in logistics. Pocus defines a PQL as an actual user who has experienced the product's core value, matches the ICP, and shows buying intent (Pocus guide). Example: a free user at a 250-person company makes 130 successful API calls from a work email. That user is a PQL candidate. A student with 130 calls and a Gmail address is not. PQA has no single standard, so write your own first.
Events and tracking plans
An event records that something happened: who, what, when, and details. A tracking plan is the written list of every event, its name, properties and owner. Without one, engineers send signup, Signed Up and user_registered, and scores break.
Two real conventions disagree, so pick one:
- Segment: Title Case names built from an object and an action, no dynamic values in names, snake_case properties, and no event for something that should be a property. The Track spec recommends a noun and a past-tense verb, such as
Order Completed(tracking plan guide). - PostHog: lowercase snake_case in a
category:object_actionpattern with present-tense verbs, such asaccount_settings:forgot_password_button_click(PostHog).
This module uses the Segment style. The project tracking plan:
| Event | Fires when | Key properties |
|---|---|---|
| User Signed Up | Account is created | plan, source |
| API Key Created | User makes a first key | key_type |
| API Request Completed | Each API call returns | endpoint, status_code |
| Teammate Invited | User invites a colleague | invitee_count |
Keep it small: few events, tied to business goals, with rich properties. Segment prefers Workspace Created with account_tier: business over Business Tier Workspace Created.
The Segment spec: identify, track, group
The Segment spec is a small standard for events, and RudderStack's HTTP API accepts the same event types. Learn three calls.
Identify says who a user is. Use a stable ID from your own database as userId, not an email, because emails change. Send the email as a trait (Segment's guidance).
{ "type": "identify", "userId": "97980cfea0067",
"traits": { "name": "Peter Gibbons", "email": "[email protected]" } }Track says what a user did. Segment uses a unique messageId to remove duplicates. If you build requests yourself, keep it unique and under 100 characters. Your receiver uses it as its de-duplication key too. When you import old data, always pass timestamp.
{ "type": "track", "userId": "97980cfea0067", "event": "API Request Completed",
"messageId": "7f3c2e1a-0b6d-4c1e-9a55-2d1f6e8b4c10",
"properties": { "endpoint": "/v1/transcribe", "status_code": 200 },
"context": { "groupId": "acct_123" }, "timestamp": "2026-10-07T14:03:11Z" }Group ties a user to an account and stores account traits. B2B needs it because you sell to companies. Reserved traits include name, industry, employees, plan and createdAt. Setting context.groupId on any call attributes that call to an account.
{ "type": "group", "userId": "97980cfea0067", "groupId": "acct_123",
"traits": { "name": "Initech", "employees": 329, "plan": "free",
"createdAt": "2026-10-05T09:00:00Z" } }Spec pages: Track, Identify, Group.
Finding the aha moment
The aha moment is when a user first feels the product's value. The activation event is the measurable action you use as a proxy. Find it by looking backward: find the early action most retained users took and most lost users did not (Mode).
This scan uses 2,000 fake accounts, each with successful API calls in the first 7 days (calls_7d) and a flag for still active at day 60 (retained_d60). Save this as fake_scan.py and run it. The data is built so that more use means more retention, so it teaches the method, not a finding.
# fake_scan.py: build 2,000 fake accounts in fake.db (stdlib only)
import random
import sqlite3
random.seed(7) # same seed, same data every run
db = sqlite3.connect("fake.db")
db.executescript("""
DROP TABLE IF EXISTS calls_7d;
DROP TABLE IF EXISTS accounts;
CREATE TABLE calls_7d (account_id INTEGER PRIMARY KEY, calls INTEGER);
CREATE TABLE accounts (account_id INTEGER PRIMARY KEY, retained_d60 INTEGER);
""")
for acct in range(2000):
calls = int(random.expovariate(1 / 45)) # most make few calls
p_retain = min(0.10 + 0.007 * calls, 0.70) # more use, more retention
retained = 1 if random.random() < p_retain else 0
db.execute("INSERT INTO calls_7d VALUES (?, ?)", (acct, calls))
db.execute("INSERT INTO accounts VALUES (?, ?)", (acct, retained))
db.commit()Then run this query on fake.db. For each threshold it compares accounts that reached it with accounts that did not:
WITH t(threshold) AS (VALUES (10), (25), (50), (100), (150))
SELECT t.threshold,
SUM(CASE WHEN c.calls >= t.threshold THEN 1 ELSE 0 END) AS reached,
ROUND(100.0 * AVG(CASE WHEN c.calls >= t.threshold THEN a.retained_d60 END), 1) AS ret_reached,
ROUND(100.0 * AVG(CASE WHEN c.calls < t.threshold THEN a.retained_d60 END), 1) AS ret_missed
FROM t
CROSS JOIN calls_7d c
JOIN accounts a ON a.account_id = c.account_id
GROUP BY t.threshold
ORDER BY t.threshold;Output on this seed (SQLite 3.50):
threshold | reached | retained if reached | retained if missed
10 | 1575 | 42.5% | 12.5%
25 | 1110 | 50.7% | 17.9%
50 | 644 | 62.1% | 23.7%
100 | 199 | 70.4% | 32.3%
150 | 62 | 66.1% | 35.1%Read it like an analyst. As the threshold rises, retention among those who reach it rises, but fewer accounts qualify. From 100 to 150 the retained share stops rising (70.4% to 66.1%) and only 62 accounts remain. Near 100 balances lift against volume: sales needs enough alerts to have work, and enough lift to make it worth doing.
Two famous examples, and what they are.
- Slack, 2,000 messages. In a 2015 interview, Stewart Butterfield said a team that has exchanged 2,000 messages "has tried Slack, really tried it", and that 93% of those customers still use Slack. That measures retention, not payment, and it is an interview, not an analysis.
- Facebook, 7 friends in 10 days. Chamath Palihapitiya described it in an October 2012 talk (StartupArchive). I found no original analysis, only his retelling. Mixpanel calls such numbers "a very useful illusion" and quotes Andrew Chen saying it could have been "10 friends in 12 days".
An aha threshold is a correlation: users who would love the product also use it more. To test cause, nudge a random half of new users toward the action and see if retention rises in that half. Until then it is a hypothesis, which is fine for an alert. Do not call it a law.
Activation, adoption curves and cohorts
Activation is the share of new accounts that reach the aha event in a window, for example 40% within 7 days. An adoption curve shows, per signup cohort (a group defined by when it started), the share of accounts still active in week 0, week 1, week 2. The query below reads the same accounts and events tables the project creates, so you can run it on pql.db after the simulator. week_n is whole weeks since signup.
WITH cohort AS (
SELECT account_id, created_at, strftime('%Y-%W', created_at) AS cohort_week FROM accounts
), active AS (
SELECT DISTINCT c.account_id, c.cohort_week,
CAST((julianday(e.ts) - julianday(c.created_at)) / 7 AS INTEGER) AS week_n
FROM cohort c JOIN events e ON e.account_id = c.account_id
AND e.name = 'API Request Completed' AND e.status BETWEEN 200 AND 299
AND e.ts >= c.created_at
)
SELECT a.cohort_week, a.week_n, COUNT(*) AS active_accounts,
(SELECT COUNT(*) FROM cohort c WHERE c.cohort_week = a.cohort_week) AS cohort_size
FROM active a GROUP BY a.cohort_week, a.week_n ORDER BY 1, 2;Worked example: say a cohort has 2 accounts, and 1 of them makes a successful call in week 0 and again in week 1. The rows read 2026-40, 0, 1, 2 then 2026-40, 1, 1, 2: 50% active in both weeks. A call with status 500 does not count, because the filter keeps only 200 to 299: count successful outcomes, not attempts. The simulator spans two days, so on pql.db you see only week 0. Divide active_accounts by cohort_size for the curve. A healthy curve flattens above zero. One that keeps falling means people try the product and leave.
PQL scoring: fit plus usage
Pocus names three signal families: customer fit (industry, geography, company size, user role), product usage (frequency, recency, feature usage, invites sent) and buying intent (a pricing page visit, a "talk to sales" click, seats added). The work email signal below is this module's own choice. Start with a rules-based score you can explain in one breath. The project gives fit up to 50 points (40 for 200+ employees, 25 for 20 to 199, 10 for 5 to 19, plus 10 for a work email domain) and usage up to 50 (30 for 100+ successful calls in 7 days, 10 for two or more users, 10 for 300+ calls). A PQL is a free account with the aha usage and a score of 70 or more.
Worked examples from the simulator: Acme Logistics (250 staff, work emails, 130 calls, 2 users) scores 50 for fit plus 40 for usage, so 90, and alerts. Solo Dev (1 person, Gmail, 120 calls, 1 user) scores 0 plus 30, so 30, and does not.
The weights are guesses. After a few months, join PQL flags to closed-won deals and see which signals predicted a sale. Until you have hundreds of outcomes, a rule sales trusts beats a model nobody understands. Route by segment (Pocus): high fit and high usage go to an account executive (AE, who closes deals); high usage with low fit goes to a sales development rep (SDR, who qualifies leads) or product marketing; low on both stays self-serve.
Real-time thresholds and debouncing
"The second a user hits the threshold" hides traps. Four rules help.
- Use event time. Events arrive late and out of order, so compare event
timestampwith signup time, both in UTC. - De-duplicate by message ID. Pipelines retry. A primary key on
messageIdmakes a replayed batch change nothing. This property is called idempotency: running the same input twice gives the same result as running it once. - Alert once per account per rule. This is debouncing: many events cross a threshold, but only the first should page a human. Store it in the database so a restart cannot reset it. A primary key on
(account_id, rule)makes the claim atomic (only one caller wins). - Claim, send, release on failure. Insert the claim, send the message, and delete the claim if the send fails so the next event retries. Sending first risks a double alert after a crash. Claiming first risks the opposite: a crash between claim and send loses the alert. Production systems close this gap with an outbox table (see the stretch goal).
The project uses a fixed window: the first 7 days after signup.
Key terms
The tools
A customer data platform (CDP) collects events once and forwards them to many tools. Product analytics tools add funnels (the share of users finishing each step in order), retention and cohorts.
| Tool | What it does | Pricing model | Notes |
|---|---|---|---|
| Segment | Customer data platform that collects events and routes them to other tools | Free plan, then paid by visitors per month | Part of Twilio. Free: 1,000 visitors per month. Team from $120 per month for 10,000 (pricing, read 7 October 2026). |
| RudderStack | Segment-style CDP with a warehouse-first design | Free plan, then paid by events per month | Free: 250K events per month. Growth from $265 per month (1M events). Self-hosted version under the Elastic License 2.0. Its auth docs disagree on whether the write key is the Basic auth username or password; on a 401, try the other. |
| PostHog | Product analytics, session replay and feature flags | Usage-based with a free monthly allowance | Free: 1M analytics events per month. Open-source core (MIT, except an ee folder). Self-hosting is for about 100k events per month, with no support. Best free path. |
| Pocus | Product-led sales platform that scores PQLs | Sales-led, no public price found | Announced on 19 March 2026 that it is joining Apollo. New users see a waitlist button. |
| Slack incoming webhooks | Posts a message to a channel from a URL | Included with Slack, no separate fee found | POST JSON with a text key. The URL is a secret. Check your plan's app limits. |
Build the project
You will build a small service that receives Segment-shaped events, stores them in SQLite, scores each free account, and posts one Slack alert the first time an account crosses the aha threshold with enough fit. It is stdlib-only Python. It runs on a simulator, and the receiver can take real Segment webhooks (a webhook is a URL that receives an HTTP POST when something happens) after you adapt it.
| Part | Job |
|---|---|
| Sender | Emits identify, group and track events (your app, or the simulator) |
Receiver (serve) | Checks an HMAC signature (a keyed hash of the body that proves the sender knows a shared secret), then parses events |
| Store (SQLite) | De-duplicates by message ID, holds accounts and alerts |
Scorer (evaluate()) | Counts successful calls in the window, scores fit plus usage |
Alerter (send_slack()) | Claims the alert, posts to Slack, releases on failure |
No-budget path: nothing here is paid. The simulator makes fake events, and without SLACK_WEBHOOK_URL the script prints the alert. For real events use a PostHog or Segment free tier. For a real message, make a free Slack workspace.
- Write your tracking plan: copy the four-event table into a doc and add an owner column.
- Save the script below as
aha_alert.pyin an empty folder. - Open two terminals there. In both, set
INGEST_SECRETto the same long random string. LeaveSLACK_WEBHOOK_URLunset. - In terminal 1, start the receiver with
python aha_alert.py serve. - In terminal 2, run
python aha_alert.py simulate, then read the receiver output in terminal 1. - Confirm terminal 1 shows exactly one
[dry run]alert line, then runsimulateagain and confirm no new alert line appears. - Save
fake_scan.pyfrom the aha moment section, run it, run the threshold scan SQL onfake.db, and pick a threshold. - Create a Slack incoming webhook. Stop the receiver, delete
pql.db, setSLACK_WEBHOOK_URLin terminal 1, restart the receiver, and runsimulateto see a real message. - Stop the receiver, delete
pql.db, setSLACK_WEBHOOK_URLtonot-a-url, restart, runsimulate, and confirmslack failedlines and no alert. - Unset
SLACK_WEBHOOK_URL, restart the receiver without deletingpql.db, runsimulate, and confirm the alert fires once.
I ran these steps on Python 3.14.3 on 7 October 2026. The simulator sends 298 events, one alert fires for Acme, Solo Dev is logged as usage-only, and the replay changes nothing. I did not test against a live Slack channel.
#!/usr/bin/env python3
"""aha_alert.py: Slack alert when a free account hits the aha moment.
Stdlib only. Usage: python aha_alert.py serve | simulate"""
import hashlib
import hmac
import json
import os
import sqlite3
import sys
import urllib.request
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
DB_PATH = os.environ.get("PQL_DB", "pql.db")
SECRET = os.environ.get("INGEST_SECRET", "dev-secret").encode()
SLACK_URL = os.environ.get("SLACK_WEBHOOK_URL", "") # empty: print
AHA_EVENT = "API Request Completed"
AHA_CALLS = 100 # successful calls ...
AHA_DAYS = 7 # ... within this many days of signup
PQL_MIN_SCORE = 70
RULE = "aha_api_100_in_7d"
FREE_MAIL = {"gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com"}
SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
message_id TEXT PRIMARY KEY, account_id TEXT, user_id TEXT,
name TEXT, ts TEXT, status INTEGER);
CREATE INDEX IF NOT EXISTS ix_events ON events (account_id, name, ts);
CREATE TABLE IF NOT EXISTS accounts (
account_id TEXT PRIMARY KEY, name TEXT, plan TEXT,
employees INTEGER, created_at TEXT);
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, email TEXT, account_id TEXT);
CREATE TABLE IF NOT EXISTS alerts (
account_id TEXT, rule TEXT, fired_at TEXT, score INTEGER,
PRIMARY KEY (account_id, rule));
"""
def connect():
db = sqlite3.connect(DB_PATH)
db.row_factory = sqlite3.Row
db.executescript(SCHEMA)
return db
def to_utc(s):
d = datetime.fromisoformat(s.replace("Z", "+00:00"))
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
def fmt(d):
return d.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
def score_account(acct, calls, users, emails):
n = acct["employees"] or 0
fit = 40 if n >= 200 else 25 if n >= 20 else 10 if n >= 5 else 0
if any(e.split("@")[-1].lower() not in FREE_MAIL for e in emails):
fit += 10
usage = 30 if calls >= AHA_CALLS else 0
usage += 10 if users >= 2 else 0
usage += 10 if calls >= 3 * AHA_CALLS else 0
return fit + usage, {"fit": fit, "usage": usage}
def evaluate(db, account_id):
acct = db.execute("SELECT * FROM accounts WHERE account_id = ?",
(account_id,)).fetchone()
if not acct or acct["plan"] != "free" or not acct["created_at"]:
return None
start = to_utc(acct["created_at"])
row = db.execute(
"SELECT COUNT(*) AS calls, COUNT(DISTINCT user_id) AS users FROM events "
"WHERE account_id = ? AND name = ? AND status BETWEEN 200 AND 299 "
"AND ts >= ? AND ts < ?",
(account_id, AHA_EVENT, fmt(start), fmt(start + timedelta(days=AHA_DAYS))),
).fetchone()
if row["calls"] < AHA_CALLS:
return None
emails = [r["email"] for r in db.execute(
"SELECT email FROM users WHERE account_id = ? AND email IS NOT NULL",
(account_id,))]
score, parts = score_account(acct, row["calls"], row["users"], emails)
if score < PQL_MIN_SCORE:
if row["calls"] == AHA_CALLS: # log once
print(f"usage-only, no alert: {account_id} score={score} {parts}")
return None
# Claim the alert (the primary key lets only one caller win).
# A crash between claim and send loses this alert.
claim = db.execute(
"INSERT OR IGNORE INTO alerts (account_id, rule, fired_at, score) "
"VALUES (?, ?, ?, ?)",
(account_id, RULE, fmt(datetime.now(timezone.utc)), score))
db.commit()
if claim.rowcount == 0:
return None # already alerted: debounce
msg = (f":rotating_light: PQL: {acct['name']} ({account_id}) has {row['calls']} "
f"successful API calls, {row['users']} user(s), "
f"{acct['employees']} employees, score {score} {parts}.")
try:
send_slack(msg)
except Exception as exc: # release the claim
db.execute("DELETE FROM alerts WHERE account_id = ? AND rule = ?",
(account_id, RULE))
db.commit()
print(f"slack failed, will retry: {exc}", file=sys.stderr)
return None
return score
def send_slack(text):
if not SLACK_URL:
print(f"[dry run] {text}")
return
req = urllib.request.Request(
SLACK_URL, data=json.dumps({"text": text}).encode(),
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()
def handle_event(db, ev):
kind, uid = ev.get("type"), ev.get("userId")
if kind == "identify":
email = (ev.get("traits") or {}).get("email")
db.execute("INSERT INTO users (user_id, email) VALUES (?, ?) "
"ON CONFLICT(user_id) DO UPDATE SET "
"email = COALESCE(excluded.email, users.email)",
(uid, email))
elif kind == "group":
t, gid = ev.get("traits") or {}, ev.get("groupId")
db.execute(
"INSERT INTO accounts (account_id, name, plan, employees, created_at) "
"VALUES (?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET "
"name = COALESCE(excluded.name, accounts.name), "
"plan = COALESCE(excluded.plan, accounts.plan), "
"employees = COALESCE(excluded.employees, accounts.employees), "
"created_at = COALESCE(excluded.created_at, accounts.created_at)",
(gid, t.get("name"), t.get("plan"), t.get("employees"), t.get("createdAt")))
db.execute("INSERT INTO users (user_id, account_id) VALUES (?, ?) "
"ON CONFLICT(user_id) DO UPDATE SET account_id = excluded.account_id",
(uid, gid))
elif kind == "track":
gid = (ev.get("context") or {}).get("groupId")
if not gid:
r = db.execute("SELECT account_id FROM users WHERE user_id = ?",
(uid,)).fetchone()
gid = r["account_id"] if r else None
if not gid or not ev.get("messageId"):
return
status = (ev.get("properties") or {}).get("status_code")
when = to_utc(ev["timestamp"]) if ev.get("timestamp") else datetime.now(timezone.utc) # no timestamp: use arrival time
db.execute("INSERT OR IGNORE INTO events VALUES (?, ?, ?, ?, ?, ?)",
(ev["messageId"], gid, uid, ev.get("event"), fmt(when), status))
db.commit()
if ev.get("event") == AHA_EVENT:
evaluate(db, gid)
db.commit()
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
want = hmac.new(SECRET, body, hashlib.sha1).hexdigest().encode()
got = self.headers.get("X-Signature", "").encode()
if not hmac.compare_digest(want, got):
return self._reply(401, {"error": "bad signature"})
try:
data = json.loads(body)
except ValueError:
return self._reply(400, {"error": "bad json"})
events = data if isinstance(data, list) else data.get("batch", [data])
db = connect()
for ev in events:
handle_event(db, ev)
db.close()
self._reply(200, {"processed": len(events)})
def _reply(self, code, obj):
out = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
def post(events, url="http://127.0.0.1:8080/events"):
body = json.dumps({"batch": events}).encode()
sig = hmac.new(SECRET, body, hashlib.sha1).hexdigest()
req = urllib.request.Request(url, data=body, method="POST", headers={
"Content-Type": "application/json", "X-Signature": sig})
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.read().decode()
def simulate():
now = datetime.now(timezone.utc)
signup = fmt(now - timedelta(days=2))
plan = [("acct_acme", "Acme Logistics", 250,
["[email protected]", "[email protected]"], 130),
("acct_tiny", "Solo Dev", 1, ["[email protected]"], 120),
("acct_slow", "Slow Corp", 120, ["[email protected]"], 40)]
events = []
for acct, name, staff, emails, calls in plan:
for i, email in enumerate(emails):
uid = f"{acct}_u{i}"
events.append({"type": "identify", "userId": uid,
"traits": {"email": email}})
events.append({"type": "group", "userId": uid, "groupId": acct,
"traits": {"name": name, "plan": "free",
"employees": staff, "createdAt": signup}})
for n in range(calls):
when = now - timedelta(days=1, minutes=calls - n)
events.append({
"type": "track", "event": AHA_EVENT, "messageId": f"sim-{acct}-{n}",
"userId": f"{acct}_u{n % len(emails)}", "timestamp": fmt(when) + "Z",
"context": {"groupId": acct},
"properties": {"endpoint": "/v1/transcribe", "status_code": 200}})
print(post(events))
print(post(events)) # replay: no second alert
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "serve":
print("listening on http://127.0.0.1:8080/events")
HTTPServer(("127.0.0.1", 8080), Receiver).serve_forever()
elif cmd == "simulate":
simulate()
else:
sys.exit(__doc__)Run it in two terminals. In each one, set the same secret first. On Windows PowerShell use $env:INGEST_SECRET = "..." instead of export.
# terminal 1
export INGEST_SECRET="replace-with-a-long-random-string"
python aha_alert.py serve
# terminal 2
export INGEST_SECRET="replace-with-a-long-random-string"
python aha_alert.py simulateExpected receiver output: one [dry run] :rotating_light: PQL: Acme Logistics ... score 90 line and one usage-only, no alert: acct_tiny score=30 line. The simulator prints {"processed": 298} twice: the first send and the replay. If Slack stays empty, look for slack failed in terminal 1.
Sending real events. This Track call follows the Segment HTTP API docs. The write key (the secret token for one data source) is the Basic auth username, with an empty password. EU workspaces must use events.eu1.segmentapis.com, or events do not appear. Limits are 32 KB per call and 500 KB per batch. I could not run it against a live workspace, so test it with your own key. PostHog uses its own event model and does not feed aha_alert.py directly; see its Python SDK docs.
curl https://api.segment.io/v1/track -u "$SEGMENT_WRITE_KEY:" \
-H "Content-Type: application/json" \
-d '{"userId":"u1","messageId":"curl-test-0001","event":"API Request Completed","properties":{"status_code":200},"context":{"groupId":"acct_123"}}'Forwarding to your receiver. Segment's Webhooks (Actions) destination POSTs events to your URL. With a shared secret it adds an X-Signature header (hex SHA1 HMAC of the body) (docs), which the receiver checks. With batching on, Segment signs only the first event of a batch, so turn batching off or change the check. Adapt handle_event if the delivered field names differ. I did not test this path against live Segment. RudderStack's webhook destination supports custom headers (docs), so there you would check a shared secret in a header.
What done looks like. The simulator fires one alert, for the high-fit heavy user. The low-fit heavy user is logged and pages nobody. The replay fires nothing. A broken Slack URL does not lose the alert. You can explain the 100, 7 and 70 and how you would test them.
Stretch goal. Move the Slack call into an outbox table and a worker, because a slow Slack call blocks the receiver (a Slack URL that returned 404 made my simulator time out). Then alert on only a random 80% of PQLs so you can measure lift.
Common mistakes
- Alerting on usage alone. A heavy user at a one-person company is not a lead. Combine usage with fit.
- Treating a magic number as a law. Slack's 2,000 messages and Facebook's 7 friends are correlations from retellings. Test your own threshold.
- Letting event names drift. Dynamic names like
Signed Up (11-01-2019)make analysis painful. Put values in properties. - Skipping idempotency. Pipelines retry. Without a message ID key and a database claim, one crossing becomes five Slack messages.
- Counting attempts, bots and your own team. Count successful calls from real users inside the window.
- Putting secrets in code. Keep write keys, the Slack webhook URL and the signing secret in environment variables. Slack revokes leaked webhook URLs.
Check yourself
1. Which event name follows Segment's recommended convention?
Why: Segment recommends Title Case, noun plus past-tense verb, with values like tier kept in properties.
2. Teams that sent 2,000 messages show 93% still active. What is the correct conclusion?
Why: A threshold found by looking backward is a correlation: happy users do more of everything.
3. A free account with one user at a one-person company on Gmail makes 120 successful API calls in a week. What should the PQL system do?
Why: A PQL needs fit as well as usage, so poor-fit heavy use goes to a lower-priority queue.
4. The pipeline delivers the same batch twice. Which design stops a duplicate Slack alert?
Why: Durable keys make the second delivery a no-op even after a restart.
5. A colleague says "hand-raisers convert 5x higher than cold outbound" is established fact. What is the most accurate reply?
Why: The report states 5x without a source, and a survey with both numbers implies about 2.8x (25% versus 9%).
How to learn this faster
Learn first: the three Segment calls, the one event you alert on, then the SQL scan on fake data.
Deliberate-practice drill. Pick a product with a public API and a free tier. In 10 minutes write the aha event, threshold, fit rule and which sales role gets the alert. Then argue against it: what is a false positive? Repeat for three products.
5-day plan
- Day 1: Read the Segment tracking plan guide and spec pages. Write a four-event plan for your own project.
- Day 2: Create a free PostHog project, send 20 test events with the Python SDK, and build one funnel.
- Day 3: Run
fake_scan.pyand the threshold scan, then change thep_retainline infake_scan.pyand watch the best threshold move. - Day 4: Run
aha_alert.pywith the simulator, then break it: duplicate batch, bad Slack URL, late events. - Day 5: Write a one-page memo to a fictional head of sales: PQL definition, threshold, alert volume and holdout plan.
Resources
- Segment: tracking plan best practicesDocsFree
Naming, casing and which events to track first.
- Slack: incoming webhooksDocsFree
The request format and the warning about secret URLs.
- Mode: Facebook's aha momentArticleFree
A retention-based method for finding the aha action.
- Mixpanel: magic numbers are an illusionArticleFree
A sober view of the 7 friends in 10 days number.
- First Round Review: Slack's launch strategyArticleFree
The source of the 2,000 messages claim.
- Pocus: the definitive PQL guideArticleFree
Fit, usage and intent scoring and PQL segments.
- Pocus and First Round: 2021 Product-Led Sales Benchmark ReportResearchFree
Survey data on how PLG companies add sales, plus the unsourced 5x sentence.
- Lenny's Newsletter: a good free-to-paid conversion rateResearchFreemium
Free-to-paid benchmarks from 1,000+ products. Full data is paywalled.
Next module
You now know who to talk to and when. Stage 5 covers usage-based pricing, where the usage events you just collected become the bill.
Built the project and passed the quiz? Mark the stage done. Your progress stays in this browser only.
Talk to Claude instead of typing to it.
Ducktate turns what you say into clean text, on your Mac, and pastes it anywhere.
Get early access