[{"data":1,"prerenderedAt":5755},["ShallowReactive",2],{"page-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002F":3,"faq-schema-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002F":5734},{"id":4,"title":5,"body":6,"description":5724,"extension":5725,"meta":5726,"navigation":317,"path":5730,"seo":5731,"stem":5732,"__hash__":5733},"content\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Findex.md","Tracking API Usage and Analytics for Commercial Python APIs",{"type":7,"value":8,"toc":5708},"minimark",[9,13,23,26,31,44,139,146,251,254,258,265,280,749,764,780,944,948,955,973,1436,1454,1458,1469,1957,1960,2155,2162,2166,2169,2180,2651,2663,2776,2780,2783,2786,3179,3190,3555,3563,3645,3649,3652,4286,4300,4395,4403,4407,4414,4809,4824,4831,4924,4928,5100,5106,5110,5121,5131,5144,5160,5173,5177,5180,5313,5330,5591,5597,5601,5616,5620,5626,5632,5638,5655,5665,5669,5704],[10,11,5],"h1",{"id":12},"tracking-api-usage-and-analytics-for-commercial-python-apis",[14,15,16,17,22],"p",{},"If you sell API access and cannot answer \"what did customer 4471 cost us last month?\" in under ten seconds, you are not running a business, you are running a hobby with a Stripe account attached. This guide builds the measurement layer that turns raw traffic into per-key usage events, live quota counters, aggregation windows, and an invoice line item. Part of the ",[18,19,21],"a",{"href":20},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002F","Building & Monetizing API-Driven Micro-SaaS"," guide.",[14,24,25],{},"The pattern here is deliberately boring: capture one immutable event per request, buffer it in memory, flush it in batches to Postgres, keep a separate fast counter in Redis for quota decisions, and roll the raw events into progressively coarser windows on a schedule. Every piece is replaceable. What is not negotiable is that billing reads from a durable event log rather than from a counter you can lose, because a counter that vanishes when a pod restarts will eventually undercharge your largest customer and you will not notice for a quarter.",[27,28,30],"h2",{"id":29},"prerequisites","Prerequisites",[14,32,33,34,38,39,43],{},"Assume Python 3.11 or newer, an async ",[18,35,37],{"href":36},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002F","FastAPI application"," already serving authenticated traffic, and a working ",[18,40,42],{"href":41},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002F","API key authentication layer"," that resolves a request to a customer identity before the route handler runs. Usage tracking without stable key identity is just a web server log.",[45,46,51],"pre",{"className":47,"code":48,"language":49,"meta":50,"style":50},"language-bash shiki shiki-themes github-light github-dark","python -m pip install \\\n  \"fastapi>=0.115,\u003C1.0\" \\\n  \"uvicorn[standard]>=0.32\" \\\n  \"sqlalchemy[asyncio]>=2.0.36\" \\\n  \"asyncpg>=0.30\" \\\n  \"redis>=5.2\" \\\n  \"stripe>=11.1\" \\\n  \"pydantic>=2.9\" \\\n  \"httpx>=0.28\"\n","bash","",[52,53,54,77,85,93,101,109,117,125,133],"code",{"__ignoreMap":50},[55,56,59,63,67,71,74],"span",{"class":57,"line":58},"line",1,[55,60,62],{"class":61},"sScJk","python",[55,64,66],{"class":65},"sj4cs"," -m",[55,68,70],{"class":69},"sZZnC"," pip",[55,72,73],{"class":69}," install",[55,75,76],{"class":65}," \\\n",[55,78,80,83],{"class":57,"line":79},2,[55,81,82],{"class":69},"  \"fastapi>=0.115,\u003C1.0\"",[55,84,76],{"class":65},[55,86,88,91],{"class":57,"line":87},3,[55,89,90],{"class":69},"  \"uvicorn[standard]>=0.32\"",[55,92,76],{"class":65},[55,94,96,99],{"class":57,"line":95},4,[55,97,98],{"class":69},"  \"sqlalchemy[asyncio]>=2.0.36\"",[55,100,76],{"class":65},[55,102,104,107],{"class":57,"line":103},5,[55,105,106],{"class":69},"  \"asyncpg>=0.30\"",[55,108,76],{"class":65},[55,110,112,115],{"class":57,"line":111},6,[55,113,114],{"class":69},"  \"redis>=5.2\"",[55,116,76],{"class":65},[55,118,120,123],{"class":57,"line":119},7,[55,121,122],{"class":69},"  \"stripe>=11.1\"",[55,124,76],{"class":65},[55,126,128,131],{"class":57,"line":127},8,[55,129,130],{"class":69},"  \"pydantic>=2.9\"",[55,132,76],{"class":65},[55,134,136],{"class":57,"line":135},9,[55,137,138],{"class":69},"  \"httpx>=0.28\"\n",[14,140,141,142,145],{},"Set these before you start. Every snippet below reads configuration through ",[52,143,144],{},"os.getenv"," so the same image runs in staging and production without edits.",[45,147,149],{"className":47,"code":148,"language":49,"meta":50,"style":50},"export USAGE_DATABASE_URL=\"postgresql+asyncpg:\u002F\u002Fapi:secret@db.internal:5432\u002Fapiprod\"\nexport USAGE_REDIS_URL=\"redis:\u002F\u002Fcache.internal:6379\u002F2\"\nexport USAGE_FLUSH_INTERVAL_SECONDS=\"2.0\"\nexport USAGE_FLUSH_MAX_BATCH=\"500\"\nexport USAGE_QUEUE_MAXSIZE=\"20000\"\nexport USAGE_QUOTA_ENFORCED=\"true\"\nexport STRIPE_API_KEY=\"sk_live_...\"\nexport STRIPE_METER_EVENT_NAME=\"api_requests\"\n",[52,150,151,167,179,191,203,215,227,239],{"__ignoreMap":50},[55,152,153,157,161,164],{"class":57,"line":58},[55,154,156],{"class":155},"szBVR","export",[55,158,160],{"class":159},"sVt8B"," USAGE_DATABASE_URL",[55,162,163],{"class":155},"=",[55,165,166],{"class":69},"\"postgresql+asyncpg:\u002F\u002Fapi:secret@db.internal:5432\u002Fapiprod\"\n",[55,168,169,171,174,176],{"class":57,"line":79},[55,170,156],{"class":155},[55,172,173],{"class":159}," USAGE_REDIS_URL",[55,175,163],{"class":155},[55,177,178],{"class":69},"\"redis:\u002F\u002Fcache.internal:6379\u002F2\"\n",[55,180,181,183,186,188],{"class":57,"line":87},[55,182,156],{"class":155},[55,184,185],{"class":159}," USAGE_FLUSH_INTERVAL_SECONDS",[55,187,163],{"class":155},[55,189,190],{"class":69},"\"2.0\"\n",[55,192,193,195,198,200],{"class":57,"line":95},[55,194,156],{"class":155},[55,196,197],{"class":159}," USAGE_FLUSH_MAX_BATCH",[55,199,163],{"class":155},[55,201,202],{"class":69},"\"500\"\n",[55,204,205,207,210,212],{"class":57,"line":103},[55,206,156],{"class":155},[55,208,209],{"class":159}," USAGE_QUEUE_MAXSIZE",[55,211,163],{"class":155},[55,213,214],{"class":69},"\"20000\"\n",[55,216,217,219,222,224],{"class":57,"line":111},[55,218,156],{"class":155},[55,220,221],{"class":159}," USAGE_QUOTA_ENFORCED",[55,223,163],{"class":155},[55,225,226],{"class":69},"\"true\"\n",[55,228,229,231,234,236],{"class":57,"line":119},[55,230,156],{"class":155},[55,232,233],{"class":159}," STRIPE_API_KEY",[55,235,163],{"class":155},[55,237,238],{"class":69},"\"sk_live_...\"\n",[55,240,241,243,246,248],{"class":57,"line":127},[55,242,156],{"class":155},[55,244,245],{"class":159}," STRIPE_METER_EVENT_NAME",[55,247,163],{"class":155},[55,249,250],{"class":69},"\"api_requests\"\n",[14,252,253],{},"You also need a Postgres 14+ instance you control. Managed Postgres on Render, Fly, or Neon is fine; the rollup queries below use plain SQL with no extensions, so TimescaleDB is optional rather than assumed.",[27,255,257],{"id":256},"step-1-model-the-usage-event-as-an-immutable-fact","Step 1: Model the usage event as an immutable fact",[14,259,260,261,264],{},"Design the event row first, because everything downstream inherits its mistakes. A usage event records what happened, not what you decided to charge. Keep pricing out of it. If you bake ",[52,262,263],{},"amount_cents"," into the event and later change your rate card, you can no longer recompute history, and recomputing history is exactly what you will need the first time a customer disputes an invoice.",[14,266,267,268,271,272,275,276,279],{},"Store six things per request: the API key hash, the customer id, the route template, the HTTP status, the duration, and the billable unit count. The route template matters more than the raw path — ",[52,269,270],{},"\u002Fv1\u002Fdocuments\u002F8fa1\u002Fpages"," is useless for aggregation, while ",[52,273,274],{},"\u002Fv1\u002Fdocuments\u002F{doc_id}\u002Fpages"," groups cleanly. FastAPI exposes the template on ",[52,277,278],{},"request.scope[\"route\"].path",", so grab it after the handler resolves.",[45,281,284],{"className":282,"code":283,"language":62,"meta":50,"style":50},"language-python shiki shiki-themes github-light github-dark","# usage\u002Fmodels.py\nimport os\nfrom datetime import datetime\n\nfrom sqlalchemy import BigInteger, Index, Integer, SmallInteger, String, text\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\nclass UsageEvent(Base):\n    __tablename__ = \"usage_event\"\n    __table_args__ = (\n        Index(\"ix_usage_customer_time\", \"customer_id\", \"occurred_at\"),\n        Index(\"ix_usage_route_time\", \"route\", \"occurred_at\"),\n        {\"postgresql_partition_by\": \"RANGE (occurred_at)\"},\n    )\n\n    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)\n    occurred_at: Mapped[datetime] = mapped_column(\n        server_default=text(\"now()\"), primary_key=True, index=True\n    )\n    customer_id: Mapped[str] = mapped_column(String(64))\n    api_key_id: Mapped[str] = mapped_column(String(64))\n    route: Mapped[str] = mapped_column(String(160))\n    method: Mapped[str] = mapped_column(String(8))\n    status_code: Mapped[int] = mapped_column(SmallInteger)\n    duration_ms: Mapped[int] = mapped_column(Integer)\n    billable_units: Mapped[int] = mapped_column(Integer, server_default=text(\"1\"))\n\n\nDATABASE_URL = os.getenv(\"USAGE_DATABASE_URL\", \"postgresql+asyncpg:\u002F\u002Flocalhost\u002Fapi\")\n",[52,285,286,292,300,313,319,331,343,347,351,368,374,379,384,399,410,421,444,463,481,487,492,533,544,577,582,604,622,641,660,675,690,717,722,727],{"__ignoreMap":50},[55,287,288],{"class":57,"line":58},[55,289,291],{"class":290},"sJ8bj","# usage\u002Fmodels.py\n",[55,293,294,297],{"class":57,"line":79},[55,295,296],{"class":155},"import",[55,298,299],{"class":159}," os\n",[55,301,302,305,308,310],{"class":57,"line":87},[55,303,304],{"class":155},"from",[55,306,307],{"class":159}," datetime ",[55,309,296],{"class":155},[55,311,312],{"class":159}," datetime\n",[55,314,315],{"class":57,"line":95},[55,316,318],{"emptyLinePlaceholder":317},true,"\n",[55,320,321,323,326,328],{"class":57,"line":103},[55,322,304],{"class":155},[55,324,325],{"class":159}," sqlalchemy ",[55,327,296],{"class":155},[55,329,330],{"class":159}," BigInteger, Index, Integer, SmallInteger, String, text\n",[55,332,333,335,338,340],{"class":57,"line":111},[55,334,304],{"class":155},[55,336,337],{"class":159}," sqlalchemy.orm ",[55,339,296],{"class":155},[55,341,342],{"class":159}," DeclarativeBase, Mapped, mapped_column\n",[55,344,345],{"class":57,"line":119},[55,346,318],{"emptyLinePlaceholder":317},[55,348,349],{"class":57,"line":127},[55,350,318],{"emptyLinePlaceholder":317},[55,352,353,356,359,362,365],{"class":57,"line":135},[55,354,355],{"class":155},"class",[55,357,358],{"class":61}," Base",[55,360,361],{"class":159},"(",[55,363,364],{"class":61},"DeclarativeBase",[55,366,367],{"class":159},"):\n",[55,369,371],{"class":57,"line":370},10,[55,372,373],{"class":155},"    pass\n",[55,375,377],{"class":57,"line":376},11,[55,378,318],{"emptyLinePlaceholder":317},[55,380,382],{"class":57,"line":381},12,[55,383,318],{"emptyLinePlaceholder":317},[55,385,387,389,392,394,397],{"class":57,"line":386},13,[55,388,355],{"class":155},[55,390,391],{"class":61}," UsageEvent",[55,393,361],{"class":159},[55,395,396],{"class":61},"Base",[55,398,367],{"class":159},[55,400,402,405,407],{"class":57,"line":401},14,[55,403,404],{"class":159},"    __tablename__ ",[55,406,163],{"class":155},[55,408,409],{"class":69}," \"usage_event\"\n",[55,411,413,416,418],{"class":57,"line":412},15,[55,414,415],{"class":159},"    __table_args__ ",[55,417,163],{"class":155},[55,419,420],{"class":159}," (\n",[55,422,424,427,430,433,436,438,441],{"class":57,"line":423},16,[55,425,426],{"class":159},"        Index(",[55,428,429],{"class":69},"\"ix_usage_customer_time\"",[55,431,432],{"class":159},", ",[55,434,435],{"class":69},"\"customer_id\"",[55,437,432],{"class":159},[55,439,440],{"class":69},"\"occurred_at\"",[55,442,443],{"class":159},"),\n",[55,445,447,449,452,454,457,459,461],{"class":57,"line":446},17,[55,448,426],{"class":159},[55,450,451],{"class":69},"\"ix_usage_route_time\"",[55,453,432],{"class":159},[55,455,456],{"class":69},"\"route\"",[55,458,432],{"class":159},[55,460,440],{"class":69},[55,462,443],{"class":159},[55,464,466,469,472,475,478],{"class":57,"line":465},18,[55,467,468],{"class":159},"        {",[55,470,471],{"class":69},"\"postgresql_partition_by\"",[55,473,474],{"class":159},": ",[55,476,477],{"class":69},"\"RANGE (occurred_at)\"",[55,479,480],{"class":159},"},\n",[55,482,484],{"class":57,"line":483},19,[55,485,486],{"class":159},"    )\n",[55,488,490],{"class":57,"line":489},20,[55,491,318],{"emptyLinePlaceholder":317},[55,493,495,498,501,504,507,509,512,516,518,521,523,526,528,530],{"class":57,"line":494},21,[55,496,497],{"class":65},"    id",[55,499,500],{"class":159},": Mapped[",[55,502,503],{"class":65},"int",[55,505,506],{"class":159},"] ",[55,508,163],{"class":155},[55,510,511],{"class":159}," mapped_column(BigInteger, ",[55,513,515],{"class":514},"s4XuR","primary_key",[55,517,163],{"class":155},[55,519,520],{"class":65},"True",[55,522,432],{"class":159},[55,524,525],{"class":514},"autoincrement",[55,527,163],{"class":155},[55,529,520],{"class":65},[55,531,532],{"class":159},")\n",[55,534,536,539,541],{"class":57,"line":535},22,[55,537,538],{"class":159},"    occurred_at: Mapped[datetime] ",[55,540,163],{"class":155},[55,542,543],{"class":159}," mapped_column(\n",[55,545,547,550,552,555,558,561,563,565,567,569,572,574],{"class":57,"line":546},23,[55,548,549],{"class":514},"        server_default",[55,551,163],{"class":155},[55,553,554],{"class":159},"text(",[55,556,557],{"class":69},"\"now()\"",[55,559,560],{"class":159},"), ",[55,562,515],{"class":514},[55,564,163],{"class":155},[55,566,520],{"class":65},[55,568,432],{"class":159},[55,570,571],{"class":514},"index",[55,573,163],{"class":155},[55,575,576],{"class":65},"True\n",[55,578,580],{"class":57,"line":579},24,[55,581,486],{"class":159},[55,583,585,588,591,593,595,598,601],{"class":57,"line":584},25,[55,586,587],{"class":159},"    customer_id: Mapped[",[55,589,590],{"class":65},"str",[55,592,506],{"class":159},[55,594,163],{"class":155},[55,596,597],{"class":159}," mapped_column(String(",[55,599,600],{"class":65},"64",[55,602,603],{"class":159},"))\n",[55,605,607,610,612,614,616,618,620],{"class":57,"line":606},26,[55,608,609],{"class":159},"    api_key_id: Mapped[",[55,611,590],{"class":65},[55,613,506],{"class":159},[55,615,163],{"class":155},[55,617,597],{"class":159},[55,619,600],{"class":65},[55,621,603],{"class":159},[55,623,625,628,630,632,634,636,639],{"class":57,"line":624},27,[55,626,627],{"class":159},"    route: Mapped[",[55,629,590],{"class":65},[55,631,506],{"class":159},[55,633,163],{"class":155},[55,635,597],{"class":159},[55,637,638],{"class":65},"160",[55,640,603],{"class":159},[55,642,644,647,649,651,653,655,658],{"class":57,"line":643},28,[55,645,646],{"class":159},"    method: Mapped[",[55,648,590],{"class":65},[55,650,506],{"class":159},[55,652,163],{"class":155},[55,654,597],{"class":159},[55,656,657],{"class":65},"8",[55,659,603],{"class":159},[55,661,663,666,668,670,672],{"class":57,"line":662},29,[55,664,665],{"class":159},"    status_code: Mapped[",[55,667,503],{"class":65},[55,669,506],{"class":159},[55,671,163],{"class":155},[55,673,674],{"class":159}," mapped_column(SmallInteger)\n",[55,676,678,681,683,685,687],{"class":57,"line":677},30,[55,679,680],{"class":159},"    duration_ms: Mapped[",[55,682,503],{"class":65},[55,684,506],{"class":159},[55,686,163],{"class":155},[55,688,689],{"class":159}," mapped_column(Integer)\n",[55,691,693,696,698,700,702,705,708,710,712,715],{"class":57,"line":692},31,[55,694,695],{"class":159},"    billable_units: Mapped[",[55,697,503],{"class":65},[55,699,506],{"class":159},[55,701,163],{"class":155},[55,703,704],{"class":159}," mapped_column(Integer, ",[55,706,707],{"class":514},"server_default",[55,709,163],{"class":155},[55,711,554],{"class":159},[55,713,714],{"class":69},"\"1\"",[55,716,603],{"class":159},[55,718,720],{"class":57,"line":719},32,[55,721,318],{"emptyLinePlaceholder":317},[55,723,725],{"class":57,"line":724},33,[55,726,318],{"emptyLinePlaceholder":317},[55,728,730,733,736,739,742,744,747],{"class":57,"line":729},34,[55,731,732],{"class":65},"DATABASE_URL",[55,734,735],{"class":155}," =",[55,737,738],{"class":159}," os.getenv(",[55,740,741],{"class":69},"\"USAGE_DATABASE_URL\"",[55,743,432],{"class":159},[55,745,746],{"class":69},"\"postgresql+asyncpg:\u002F\u002Flocalhost\u002Fapi\"",[55,748,532],{"class":159},[14,750,751,752,755,756,759,760,763],{},"Note ",[52,753,754],{},"billable_units",". Not every request is worth the same. A document extraction that processes 40 pages should write ",[52,757,758],{},"billable_units=40"," while a health check writes zero. Decide this inside the handler and stash it on ",[52,761,762],{},"request.state"," so the middleware can read it on the way out. That one integer is the difference between charging per call and charging per unit of value delivered, and it is the single highest-leverage field in the whole schema.",[14,765,766,767,770,771,774,775,779],{},"Partition the table by month from day one. ",[52,768,769],{},"RANGE (occurred_at)"," with monthly child tables makes dropping expired raw data a metadata operation instead of a multi-hour ",[52,772,773],{},"DELETE"," that bloats your write-ahead log. The detailed DDL, partition automation, and index tuning live in ",[18,776,778],{"href":777},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Flogging-api-usage-events-to-postgres\u002F","logging API usage events to Postgres",".",[781,782,790,791,790,795,790,799,790,806,790,815,790,823,790,829,790,835,790,839,790,842,790,845,790,849,790,852,790,856,790,861,790,865,790,869,790,873,790,879,790,882,790,887,790,892,790,896,790,899,790,903,790,906,790,909,790,912,790,916,790,920,790,925,790,929,790,933,790,936,790,940],"svg",{"viewBox":783,"role":784,"ariaLabelledBy":785,"xmlns":788,"style":789},"0 0 760 330","img",[786,787],"tau-flow-t","tau-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[792,793,794],"title",{"id":786},"Usage capture pipeline from request to Postgres",[796,797,798],"desc",{"id":787},"A request passes through usage middleware, which increments a Redis quota counter on the hot path and pushes an event onto an in-process queue that a batch flusher drains into a partitioned Postgres table.",[800,801],"rect",{"x":802,"y":802,"width":803,"height":804,"fill":805},"0","760","330","var(--c-surface)",[800,807],{"x":808,"y":809,"width":810,"height":811,"rx":657,"fill":812,"stroke":813,"style":814},"20","28","150","52","var(--c-surface-alt)","var(--c-border)","stroke-width:1.5;",[816,817,822],"text",{"x":818,"y":819,"fill":820,"style":821},"95","50","var(--c-text)","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Client + API key",[816,824,828],{"x":818,"y":825,"fill":826,"style":827},"68","var(--c-text-muted)","text-anchor:middle;font-size:11;font-family:var(--font-sans);","Bearer token",[800,830],{"x":831,"y":809,"width":832,"height":811,"rx":657,"fill":805,"stroke":833,"style":834},"210","170","var(--c-blue)","stroke-width:2;",[816,836,838],{"x":837,"y":819,"fill":820,"style":821},"295","Usage middleware",[816,840,841],{"x":837,"y":825,"fill":826,"style":827},"adds ~0.3 ms",[800,843],{"x":844,"y":809,"width":810,"height":811,"rx":657,"fill":812,"stroke":813,"style":814},"420",[816,846,848],{"x":847,"y":819,"fill":820,"style":821},"495","Route handler",[816,850,851],{"x":847,"y":825,"fill":826,"style":827},"sets units",[800,853],{"x":854,"y":809,"width":855,"height":811,"rx":657,"fill":812,"stroke":813,"style":814},"610","130",[816,857,860],{"x":858,"y":859,"fill":820,"style":821},"675","58","Response",[57,862],{"x1":832,"y1":863,"x2":864,"y2":863,"stroke":826,"style":814},"54","208",[57,866],{"x1":867,"y1":863,"x2":868,"y2":863,"stroke":826,"style":814},"380","418",[57,870],{"x1":871,"y1":863,"x2":872,"y2":863,"stroke":826,"style":814},"570","608",[57,874],{"x1":875,"y1":876,"x2":638,"y2":877,"stroke":878,"style":834},"250","80","140","var(--c-coral)",[57,880],{"x1":804,"y1":876,"x2":844,"y2":877,"stroke":881,"style":834},"var(--c-teal)",[800,883],{"x":884,"y":885,"width":886,"height":859,"rx":657,"fill":805,"stroke":878,"style":834},"40","142","190",[816,888,891],{"x":889,"y":890,"fill":820,"style":821},"135","165","Redis quota counter",[816,893,895],{"x":889,"y":894,"fill":826,"style":827},"184","blocking, decides 429",[800,897],{"x":804,"y":885,"width":898,"height":859,"rx":657,"fill":805,"stroke":881,"style":834},"200",[816,900,902],{"x":901,"y":890,"fill":820,"style":821},"430","In-process queue",[816,904,905],{"x":901,"y":894,"fill":826,"style":827},"put_nowait, never awaits",[57,907],{"x1":901,"y1":898,"x2":901,"y2":908,"stroke":881,"style":834},"236",[800,910],{"x":804,"y":911,"width":898,"height":859,"rx":657,"fill":805,"stroke":881,"style":834},"238",[816,913,915],{"x":901,"y":914,"fill":820,"style":821},"261","Batch flusher task",[816,917,919],{"x":901,"y":918,"fill":826,"style":827},"280","2 s or 500 rows",[57,921],{"x1":922,"y1":923,"x2":924,"y2":923,"stroke":881,"style":834},"530","267","576",[800,926],{"x":927,"y":911,"width":928,"height":859,"rx":657,"fill":805,"stroke":833,"style":834},"578","162",[816,930,932],{"x":931,"y":914,"fill":820,"style":821},"659","usage_event",[816,934,935],{"x":931,"y":918,"fill":826,"style":827},"partitioned monthly",[816,937,939],{"x":889,"y":938,"fill":826,"style":827},"222","source of truth: enforcement",[816,941,943],{"x":931,"y":942,"fill":826,"style":827},"316","source of truth: billing",[27,945,947],{"id":946},"step-2-capture-events-in-middleware-without-slowing-the-hot-path","Step 2: Capture events in middleware without slowing the hot path",[14,949,950,951,954],{},"The cardinal rule of usage instrumentation: never write to your primary database inside the request cycle. A single ",[52,952,953],{},"INSERT"," per request costs a round trip, a connection from the pool, and a WAL flush. At 300 requests per second that is 300 extra transactions per second on the same Postgres instance serving your product, and it will exhaust your pool long before it exhausts your CPU.",[14,956,957,958,961,962,965,966,968,969,972],{},"Instead, push a tuple onto an ",[52,959,960],{},"asyncio.Queue"," with ",[52,963,964],{},"put_nowait"," and return immediately. ",[52,967,964],{}," raises ",[52,970,971],{},"QueueFull"," rather than blocking, which is precisely the behaviour you want: under catastrophic backpressure you drop analytics rather than drop revenue-generating requests. Count the drops in a metric so you know when the buffer is undersized.",[45,974,976],{"className":282,"code":975,"language":62,"meta":50,"style":50},"# usage\u002Fmiddleware.py\nimport asyncio\nimport os\nimport time\nfrom datetime import datetime, timezone\n\nfrom starlette.middleware.base import BaseHTTPMiddleware\nfrom starlette.requests import Request\nfrom starlette.responses import Response\n\nQUEUE_MAXSIZE = int(os.getenv(\"USAGE_QUEUE_MAXSIZE\", \"20000\"))\nusage_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=QUEUE_MAXSIZE)\ndropped_events = 0\n\n\nclass UsageMiddleware(BaseHTTPMiddleware):\n    async def dispatch(self, request: Request, call_next) -> Response:\n        started = time.perf_counter()\n        response = await call_next(request)\n        elapsed_ms = int((time.perf_counter() - started) * 1000)\n\n        principal = getattr(request.state, \"principal\", None)\n        if principal is None:\n            return response  # unauthenticated traffic is not billable\n\n        route = request.scope.get(\"route\")\n        event = {\n            \"occurred_at\": datetime.now(timezone.utc),\n            \"customer_id\": principal.customer_id,\n            \"api_key_id\": principal.api_key_id,\n            \"route\": getattr(route, \"path\", request.url.path),\n            \"method\": request.method,\n            \"status_code\": response.status_code,\n            \"duration_ms\": elapsed_ms,\n            \"billable_units\": getattr(request.state, \"billable_units\", 1),\n        }\n\n        global dropped_events\n        try:\n            usage_queue.put_nowait(event)\n        except asyncio.QueueFull:\n            dropped_events += 1\n        return response\n",[52,977,978,983,990,996,1003,1014,1018,1030,1042,1054,1058,1081,1105,1115,1119,1123,1137,1151,1161,1174,1200,1204,1227,1244,1255,1259,1273,1283,1291,1299,1307,1326,1334,1342,1350,1372,1378,1383,1392,1400,1406,1415,1427],{"__ignoreMap":50},[55,979,980],{"class":57,"line":58},[55,981,982],{"class":290},"# usage\u002Fmiddleware.py\n",[55,984,985,987],{"class":57,"line":79},[55,986,296],{"class":155},[55,988,989],{"class":159}," asyncio\n",[55,991,992,994],{"class":57,"line":87},[55,993,296],{"class":155},[55,995,299],{"class":159},[55,997,998,1000],{"class":57,"line":95},[55,999,296],{"class":155},[55,1001,1002],{"class":159}," time\n",[55,1004,1005,1007,1009,1011],{"class":57,"line":103},[55,1006,304],{"class":155},[55,1008,307],{"class":159},[55,1010,296],{"class":155},[55,1012,1013],{"class":159}," datetime, timezone\n",[55,1015,1016],{"class":57,"line":111},[55,1017,318],{"emptyLinePlaceholder":317},[55,1019,1020,1022,1025,1027],{"class":57,"line":119},[55,1021,304],{"class":155},[55,1023,1024],{"class":159}," starlette.middleware.base ",[55,1026,296],{"class":155},[55,1028,1029],{"class":159}," BaseHTTPMiddleware\n",[55,1031,1032,1034,1037,1039],{"class":57,"line":127},[55,1033,304],{"class":155},[55,1035,1036],{"class":159}," starlette.requests ",[55,1038,296],{"class":155},[55,1040,1041],{"class":159}," Request\n",[55,1043,1044,1046,1049,1051],{"class":57,"line":135},[55,1045,304],{"class":155},[55,1047,1048],{"class":159}," starlette.responses ",[55,1050,296],{"class":155},[55,1052,1053],{"class":159}," Response\n",[55,1055,1056],{"class":57,"line":370},[55,1057,318],{"emptyLinePlaceholder":317},[55,1059,1060,1063,1065,1068,1071,1074,1076,1079],{"class":57,"line":376},[55,1061,1062],{"class":65},"QUEUE_MAXSIZE",[55,1064,735],{"class":155},[55,1066,1067],{"class":65}," int",[55,1069,1070],{"class":159},"(os.getenv(",[55,1072,1073],{"class":69},"\"USAGE_QUEUE_MAXSIZE\"",[55,1075,432],{"class":159},[55,1077,1078],{"class":69},"\"20000\"",[55,1080,603],{"class":159},[55,1082,1083,1086,1089,1091,1093,1096,1099,1101,1103],{"class":57,"line":381},[55,1084,1085],{"class":159},"usage_queue: asyncio.Queue[",[55,1087,1088],{"class":65},"dict",[55,1090,506],{"class":159},[55,1092,163],{"class":155},[55,1094,1095],{"class":159}," asyncio.Queue(",[55,1097,1098],{"class":514},"maxsize",[55,1100,163],{"class":155},[55,1102,1062],{"class":65},[55,1104,532],{"class":159},[55,1106,1107,1110,1112],{"class":57,"line":386},[55,1108,1109],{"class":159},"dropped_events ",[55,1111,163],{"class":155},[55,1113,1114],{"class":65}," 0\n",[55,1116,1117],{"class":57,"line":401},[55,1118,318],{"emptyLinePlaceholder":317},[55,1120,1121],{"class":57,"line":412},[55,1122,318],{"emptyLinePlaceholder":317},[55,1124,1125,1127,1130,1132,1135],{"class":57,"line":423},[55,1126,355],{"class":155},[55,1128,1129],{"class":61}," UsageMiddleware",[55,1131,361],{"class":159},[55,1133,1134],{"class":61},"BaseHTTPMiddleware",[55,1136,367],{"class":159},[55,1138,1139,1142,1145,1148],{"class":57,"line":446},[55,1140,1141],{"class":155},"    async",[55,1143,1144],{"class":155}," def",[55,1146,1147],{"class":61}," dispatch",[55,1149,1150],{"class":159},"(self, request: Request, call_next) -> Response:\n",[55,1152,1153,1156,1158],{"class":57,"line":465},[55,1154,1155],{"class":159},"        started ",[55,1157,163],{"class":155},[55,1159,1160],{"class":159}," time.perf_counter()\n",[55,1162,1163,1166,1168,1171],{"class":57,"line":483},[55,1164,1165],{"class":159},"        response ",[55,1167,163],{"class":155},[55,1169,1170],{"class":155}," await",[55,1172,1173],{"class":159}," call_next(request)\n",[55,1175,1176,1179,1181,1183,1186,1189,1192,1195,1198],{"class":57,"line":489},[55,1177,1178],{"class":159},"        elapsed_ms ",[55,1180,163],{"class":155},[55,1182,1067],{"class":65},[55,1184,1185],{"class":159},"((time.perf_counter() ",[55,1187,1188],{"class":155},"-",[55,1190,1191],{"class":159}," started) ",[55,1193,1194],{"class":155},"*",[55,1196,1197],{"class":65}," 1000",[55,1199,532],{"class":159},[55,1201,1202],{"class":57,"line":494},[55,1203,318],{"emptyLinePlaceholder":317},[55,1205,1206,1209,1211,1214,1217,1220,1222,1225],{"class":57,"line":535},[55,1207,1208],{"class":159},"        principal ",[55,1210,163],{"class":155},[55,1212,1213],{"class":65}," getattr",[55,1215,1216],{"class":159},"(request.state, ",[55,1218,1219],{"class":69},"\"principal\"",[55,1221,432],{"class":159},[55,1223,1224],{"class":65},"None",[55,1226,532],{"class":159},[55,1228,1229,1232,1235,1238,1241],{"class":57,"line":546},[55,1230,1231],{"class":155},"        if",[55,1233,1234],{"class":159}," principal ",[55,1236,1237],{"class":155},"is",[55,1239,1240],{"class":65}," None",[55,1242,1243],{"class":159},":\n",[55,1245,1246,1249,1252],{"class":57,"line":579},[55,1247,1248],{"class":155},"            return",[55,1250,1251],{"class":159}," response  ",[55,1253,1254],{"class":290},"# unauthenticated traffic is not billable\n",[55,1256,1257],{"class":57,"line":584},[55,1258,318],{"emptyLinePlaceholder":317},[55,1260,1261,1264,1266,1269,1271],{"class":57,"line":606},[55,1262,1263],{"class":159},"        route ",[55,1265,163],{"class":155},[55,1267,1268],{"class":159}," request.scope.get(",[55,1270,456],{"class":69},[55,1272,532],{"class":159},[55,1274,1275,1278,1280],{"class":57,"line":624},[55,1276,1277],{"class":159},"        event ",[55,1279,163],{"class":155},[55,1281,1282],{"class":159}," {\n",[55,1284,1285,1288],{"class":57,"line":643},[55,1286,1287],{"class":69},"            \"occurred_at\"",[55,1289,1290],{"class":159},": datetime.now(timezone.utc),\n",[55,1292,1293,1296],{"class":57,"line":662},[55,1294,1295],{"class":69},"            \"customer_id\"",[55,1297,1298],{"class":159},": principal.customer_id,\n",[55,1300,1301,1304],{"class":57,"line":677},[55,1302,1303],{"class":69},"            \"api_key_id\"",[55,1305,1306],{"class":159},": principal.api_key_id,\n",[55,1308,1309,1312,1314,1317,1320,1323],{"class":57,"line":692},[55,1310,1311],{"class":69},"            \"route\"",[55,1313,474],{"class":159},[55,1315,1316],{"class":65},"getattr",[55,1318,1319],{"class":159},"(route, ",[55,1321,1322],{"class":69},"\"path\"",[55,1324,1325],{"class":159},", request.url.path),\n",[55,1327,1328,1331],{"class":57,"line":719},[55,1329,1330],{"class":69},"            \"method\"",[55,1332,1333],{"class":159},": request.method,\n",[55,1335,1336,1339],{"class":57,"line":724},[55,1337,1338],{"class":69},"            \"status_code\"",[55,1340,1341],{"class":159},": response.status_code,\n",[55,1343,1344,1347],{"class":57,"line":729},[55,1345,1346],{"class":69},"            \"duration_ms\"",[55,1348,1349],{"class":159},": elapsed_ms,\n",[55,1351,1353,1356,1358,1360,1362,1365,1367,1370],{"class":57,"line":1352},35,[55,1354,1355],{"class":69},"            \"billable_units\"",[55,1357,474],{"class":159},[55,1359,1316],{"class":65},[55,1361,1216],{"class":159},[55,1363,1364],{"class":69},"\"billable_units\"",[55,1366,432],{"class":159},[55,1368,1369],{"class":65},"1",[55,1371,443],{"class":159},[55,1373,1375],{"class":57,"line":1374},36,[55,1376,1377],{"class":159},"        }\n",[55,1379,1381],{"class":57,"line":1380},37,[55,1382,318],{"emptyLinePlaceholder":317},[55,1384,1386,1389],{"class":57,"line":1385},38,[55,1387,1388],{"class":155},"        global",[55,1390,1391],{"class":159}," dropped_events\n",[55,1393,1395,1398],{"class":57,"line":1394},39,[55,1396,1397],{"class":155},"        try",[55,1399,1243],{"class":159},[55,1401,1403],{"class":57,"line":1402},40,[55,1404,1405],{"class":159},"            usage_queue.put_nowait(event)\n",[55,1407,1409,1412],{"class":57,"line":1408},41,[55,1410,1411],{"class":155},"        except",[55,1413,1414],{"class":159}," asyncio.QueueFull:\n",[55,1416,1418,1421,1424],{"class":57,"line":1417},42,[55,1419,1420],{"class":159},"            dropped_events ",[55,1422,1423],{"class":155},"+=",[55,1425,1426],{"class":65}," 1\n",[55,1428,1430,1433],{"class":57,"line":1429},43,[55,1431,1432],{"class":155},"        return",[55,1434,1435],{"class":159}," response\n",[14,1437,1438,1439,1441,1442,1444,1445,1448,1449,1453],{},"Two details earn their keep. First, the middleware records ",[52,1440,754],{}," from ",[52,1443,762],{},", so a handler that processed 40 pages simply sets ",[52,1446,1447],{},"request.state.billable_units = 40"," and the accounting follows. Second, unauthenticated requests exit early — you do not want to bill anyone for the 401s generated by a misconfigured client, and you certainly do not want an unauthenticated flood filling the buffer. Pair this with ",[18,1450,1452],{"href":1451},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","structured logging"," so that the same request id appears in both your logs and your usage rows; correlating them during a billing dispute takes minutes instead of days.",[27,1455,1457],{"id":1456},"step-3-flush-batches-with-a-background-drain-task","Step 3: Flush batches with a background drain task",[14,1459,1460,1461,1464,1465,1468],{},"The flusher is a single long-lived task started in the lifespan handler. It drains up to ",[52,1462,1463],{},"USAGE_FLUSH_MAX_BATCH"," events or waits ",[52,1466,1467],{},"USAGE_FLUSH_INTERVAL_SECONDS",", whichever comes first, then writes them in one multi-row insert. Batching 500 rows into a single statement turns 500 transactions into one, which on a small managed Postgres is the difference between 8% and 0.4% of your write capacity spent on telemetry.",[45,1470,1472],{"className":282,"code":1471,"language":62,"meta":50,"style":50},"# usage\u002Fflusher.py\nimport asyncio\nimport contextlib\nimport os\n\nfrom sqlalchemy.dialects.postgresql import insert\nfrom sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine\n\nfrom usage.models import DATABASE_URL, UsageEvent\nfrom usage.middleware import usage_queue\n\nFLUSH_INTERVAL = float(os.getenv(\"USAGE_FLUSH_INTERVAL_SECONDS\", \"2.0\"))\nMAX_BATCH = int(os.getenv(\"USAGE_FLUSH_MAX_BATCH\", \"500\"))\n\nengine = create_async_engine(DATABASE_URL, pool_size=5, max_overflow=2)\nSession = async_sessionmaker(engine, expire_on_commit=False)\n\n\nasync def _collect_batch() -> list[dict]:\n    first = await usage_queue.get()\n    batch = [first]\n    deadline = asyncio.get_running_loop().time() + FLUSH_INTERVAL\n    while len(batch) \u003C MAX_BATCH:\n        remaining = deadline - asyncio.get_running_loop().time()\n        if remaining \u003C= 0:\n            break\n        try:\n            batch.append(await asyncio.wait_for(usage_queue.get(), remaining))\n        except TimeoutError:\n            break\n    return batch\n\n\nasync def flush_loop() -> None:\n    while True:\n        batch = await _collect_batch()\n        try:\n            async with Session() as session:\n                await session.execute(insert(UsageEvent), batch)\n                await session.commit()\n        except Exception:\n            # Re-queue what fits; a persistent DB outage must not kill the task.\n            for event in batch:\n                with contextlib.suppress(asyncio.QueueFull):\n                    usage_queue.put_nowait(event)\n            await asyncio.sleep(FLUSH_INTERVAL)\n",[52,1473,1474,1479,1485,1492,1498,1502,1514,1526,1530,1545,1557,1561,1583,1604,1608,1642,1662,1666,1670,1688,1700,1710,1726,1745,1760,1775,1780,1786,1797,1806,1810,1818,1822,1826,1842,1851,1863,1869,1886,1894,1901,1910,1915,1929,1938,1944],{"__ignoreMap":50},[55,1475,1476],{"class":57,"line":58},[55,1477,1478],{"class":290},"# usage\u002Fflusher.py\n",[55,1480,1481,1483],{"class":57,"line":79},[55,1482,296],{"class":155},[55,1484,989],{"class":159},[55,1486,1487,1489],{"class":57,"line":87},[55,1488,296],{"class":155},[55,1490,1491],{"class":159}," contextlib\n",[55,1493,1494,1496],{"class":57,"line":95},[55,1495,296],{"class":155},[55,1497,299],{"class":159},[55,1499,1500],{"class":57,"line":103},[55,1501,318],{"emptyLinePlaceholder":317},[55,1503,1504,1506,1509,1511],{"class":57,"line":111},[55,1505,304],{"class":155},[55,1507,1508],{"class":159}," sqlalchemy.dialects.postgresql ",[55,1510,296],{"class":155},[55,1512,1513],{"class":159}," insert\n",[55,1515,1516,1518,1521,1523],{"class":57,"line":119},[55,1517,304],{"class":155},[55,1519,1520],{"class":159}," sqlalchemy.ext.asyncio ",[55,1522,296],{"class":155},[55,1524,1525],{"class":159}," async_sessionmaker, create_async_engine\n",[55,1527,1528],{"class":57,"line":127},[55,1529,318],{"emptyLinePlaceholder":317},[55,1531,1532,1534,1537,1539,1542],{"class":57,"line":135},[55,1533,304],{"class":155},[55,1535,1536],{"class":159}," usage.models ",[55,1538,296],{"class":155},[55,1540,1541],{"class":65}," DATABASE_URL",[55,1543,1544],{"class":159},", UsageEvent\n",[55,1546,1547,1549,1552,1554],{"class":57,"line":370},[55,1548,304],{"class":155},[55,1550,1551],{"class":159}," usage.middleware ",[55,1553,296],{"class":155},[55,1555,1556],{"class":159}," usage_queue\n",[55,1558,1559],{"class":57,"line":376},[55,1560,318],{"emptyLinePlaceholder":317},[55,1562,1563,1566,1568,1571,1573,1576,1578,1581],{"class":57,"line":381},[55,1564,1565],{"class":65},"FLUSH_INTERVAL",[55,1567,735],{"class":155},[55,1569,1570],{"class":65}," float",[55,1572,1070],{"class":159},[55,1574,1575],{"class":69},"\"USAGE_FLUSH_INTERVAL_SECONDS\"",[55,1577,432],{"class":159},[55,1579,1580],{"class":69},"\"2.0\"",[55,1582,603],{"class":159},[55,1584,1585,1588,1590,1592,1594,1597,1599,1602],{"class":57,"line":386},[55,1586,1587],{"class":65},"MAX_BATCH",[55,1589,735],{"class":155},[55,1591,1067],{"class":65},[55,1593,1070],{"class":159},[55,1595,1596],{"class":69},"\"USAGE_FLUSH_MAX_BATCH\"",[55,1598,432],{"class":159},[55,1600,1601],{"class":69},"\"500\"",[55,1603,603],{"class":159},[55,1605,1606],{"class":57,"line":401},[55,1607,318],{"emptyLinePlaceholder":317},[55,1609,1610,1613,1615,1618,1620,1622,1625,1627,1630,1632,1635,1637,1640],{"class":57,"line":412},[55,1611,1612],{"class":159},"engine ",[55,1614,163],{"class":155},[55,1616,1617],{"class":159}," create_async_engine(",[55,1619,732],{"class":65},[55,1621,432],{"class":159},[55,1623,1624],{"class":514},"pool_size",[55,1626,163],{"class":155},[55,1628,1629],{"class":65},"5",[55,1631,432],{"class":159},[55,1633,1634],{"class":514},"max_overflow",[55,1636,163],{"class":155},[55,1638,1639],{"class":65},"2",[55,1641,532],{"class":159},[55,1643,1644,1647,1649,1652,1655,1657,1660],{"class":57,"line":423},[55,1645,1646],{"class":159},"Session ",[55,1648,163],{"class":155},[55,1650,1651],{"class":159}," async_sessionmaker(engine, ",[55,1653,1654],{"class":514},"expire_on_commit",[55,1656,163],{"class":155},[55,1658,1659],{"class":65},"False",[55,1661,532],{"class":159},[55,1663,1664],{"class":57,"line":446},[55,1665,318],{"emptyLinePlaceholder":317},[55,1667,1668],{"class":57,"line":465},[55,1669,318],{"emptyLinePlaceholder":317},[55,1671,1672,1675,1677,1680,1683,1685],{"class":57,"line":483},[55,1673,1674],{"class":155},"async",[55,1676,1144],{"class":155},[55,1678,1679],{"class":61}," _collect_batch",[55,1681,1682],{"class":159},"() -> list[",[55,1684,1088],{"class":65},[55,1686,1687],{"class":159},"]:\n",[55,1689,1690,1693,1695,1697],{"class":57,"line":489},[55,1691,1692],{"class":159},"    first ",[55,1694,163],{"class":155},[55,1696,1170],{"class":155},[55,1698,1699],{"class":159}," usage_queue.get()\n",[55,1701,1702,1705,1707],{"class":57,"line":494},[55,1703,1704],{"class":159},"    batch ",[55,1706,163],{"class":155},[55,1708,1709],{"class":159}," [first]\n",[55,1711,1712,1715,1717,1720,1723],{"class":57,"line":535},[55,1713,1714],{"class":159},"    deadline ",[55,1716,163],{"class":155},[55,1718,1719],{"class":159}," asyncio.get_running_loop().time() ",[55,1721,1722],{"class":155},"+",[55,1724,1725],{"class":65}," FLUSH_INTERVAL\n",[55,1727,1728,1731,1734,1737,1740,1743],{"class":57,"line":546},[55,1729,1730],{"class":155},"    while",[55,1732,1733],{"class":65}," len",[55,1735,1736],{"class":159},"(batch) ",[55,1738,1739],{"class":155},"\u003C",[55,1741,1742],{"class":65}," MAX_BATCH",[55,1744,1243],{"class":159},[55,1746,1747,1750,1752,1755,1757],{"class":57,"line":579},[55,1748,1749],{"class":159},"        remaining ",[55,1751,163],{"class":155},[55,1753,1754],{"class":159}," deadline ",[55,1756,1188],{"class":155},[55,1758,1759],{"class":159}," asyncio.get_running_loop().time()\n",[55,1761,1762,1764,1767,1770,1773],{"class":57,"line":584},[55,1763,1231],{"class":155},[55,1765,1766],{"class":159}," remaining ",[55,1768,1769],{"class":155},"\u003C=",[55,1771,1772],{"class":65}," 0",[55,1774,1243],{"class":159},[55,1776,1777],{"class":57,"line":606},[55,1778,1779],{"class":155},"            break\n",[55,1781,1782,1784],{"class":57,"line":624},[55,1783,1397],{"class":155},[55,1785,1243],{"class":159},[55,1787,1788,1791,1794],{"class":57,"line":643},[55,1789,1790],{"class":159},"            batch.append(",[55,1792,1793],{"class":155},"await",[55,1795,1796],{"class":159}," asyncio.wait_for(usage_queue.get(), remaining))\n",[55,1798,1799,1801,1804],{"class":57,"line":662},[55,1800,1411],{"class":155},[55,1802,1803],{"class":65}," TimeoutError",[55,1805,1243],{"class":159},[55,1807,1808],{"class":57,"line":677},[55,1809,1779],{"class":155},[55,1811,1812,1815],{"class":57,"line":692},[55,1813,1814],{"class":155},"    return",[55,1816,1817],{"class":159}," batch\n",[55,1819,1820],{"class":57,"line":719},[55,1821,318],{"emptyLinePlaceholder":317},[55,1823,1824],{"class":57,"line":724},[55,1825,318],{"emptyLinePlaceholder":317},[55,1827,1828,1830,1832,1835,1838,1840],{"class":57,"line":729},[55,1829,1674],{"class":155},[55,1831,1144],{"class":155},[55,1833,1834],{"class":61}," flush_loop",[55,1836,1837],{"class":159},"() -> ",[55,1839,1224],{"class":65},[55,1841,1243],{"class":159},[55,1843,1844,1846,1849],{"class":57,"line":1352},[55,1845,1730],{"class":155},[55,1847,1848],{"class":65}," True",[55,1850,1243],{"class":159},[55,1852,1853,1856,1858,1860],{"class":57,"line":1374},[55,1854,1855],{"class":159},"        batch ",[55,1857,163],{"class":155},[55,1859,1170],{"class":155},[55,1861,1862],{"class":159}," _collect_batch()\n",[55,1864,1865,1867],{"class":57,"line":1380},[55,1866,1397],{"class":155},[55,1868,1243],{"class":159},[55,1870,1871,1874,1877,1880,1883],{"class":57,"line":1385},[55,1872,1873],{"class":155},"            async",[55,1875,1876],{"class":155}," with",[55,1878,1879],{"class":159}," Session() ",[55,1881,1882],{"class":155},"as",[55,1884,1885],{"class":159}," session:\n",[55,1887,1888,1891],{"class":57,"line":1394},[55,1889,1890],{"class":155},"                await",[55,1892,1893],{"class":159}," session.execute(insert(UsageEvent), batch)\n",[55,1895,1896,1898],{"class":57,"line":1402},[55,1897,1890],{"class":155},[55,1899,1900],{"class":159}," session.commit()\n",[55,1902,1903,1905,1908],{"class":57,"line":1408},[55,1904,1411],{"class":155},[55,1906,1907],{"class":65}," Exception",[55,1909,1243],{"class":159},[55,1911,1912],{"class":57,"line":1417},[55,1913,1914],{"class":290},"            # Re-queue what fits; a persistent DB outage must not kill the task.\n",[55,1916,1917,1920,1923,1926],{"class":57,"line":1429},[55,1918,1919],{"class":155},"            for",[55,1921,1922],{"class":159}," event ",[55,1924,1925],{"class":155},"in",[55,1927,1928],{"class":159}," batch:\n",[55,1930,1932,1935],{"class":57,"line":1931},44,[55,1933,1934],{"class":155},"                with",[55,1936,1937],{"class":159}," contextlib.suppress(asyncio.QueueFull):\n",[55,1939,1941],{"class":57,"line":1940},45,[55,1942,1943],{"class":159},"                    usage_queue.put_nowait(event)\n",[55,1945,1947,1950,1953,1955],{"class":57,"line":1946},46,[55,1948,1949],{"class":155},"            await",[55,1951,1952],{"class":159}," asyncio.sleep(",[55,1954,1565],{"class":65},[55,1956,532],{"class":159},[14,1958,1959],{},"Wire it into the application lifespan and, critically, drain the queue on shutdown. A rolling deploy that kills workers mid-buffer silently discards up to two seconds of billable events per worker. Multiply that by ten deploys a day and thirty workers and you are throwing away real money.",[45,1961,1963],{"className":282,"code":1962,"language":62,"meta":50,"style":50},"# app.py\nimport asyncio\nfrom contextlib import asynccontextmanager\n\nfrom fastapi import FastAPI\n\nfrom usage.flusher import flush_loop, usage_queue\nfrom usage.middleware import UsageMiddleware\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    task = asyncio.create_task(flush_loop())\n    yield\n    await usage_queue.join() if usage_queue.qsize() else None\n    task.cancel()\n    try:\n        await task\n    except asyncio.CancelledError:\n        pass\n\n\napp = FastAPI(lifespan=lifespan)\napp.add_middleware(UsageMiddleware)\n",[52,1964,1965,1970,1976,1988,1992,2004,2008,2020,2031,2035,2039,2044,2056,2066,2071,2091,2096,2103,2111,2119,2124,2128,2132,2150],{"__ignoreMap":50},[55,1966,1967],{"class":57,"line":58},[55,1968,1969],{"class":290},"# app.py\n",[55,1971,1972,1974],{"class":57,"line":79},[55,1973,296],{"class":155},[55,1975,989],{"class":159},[55,1977,1978,1980,1983,1985],{"class":57,"line":87},[55,1979,304],{"class":155},[55,1981,1982],{"class":159}," contextlib ",[55,1984,296],{"class":155},[55,1986,1987],{"class":159}," asynccontextmanager\n",[55,1989,1990],{"class":57,"line":95},[55,1991,318],{"emptyLinePlaceholder":317},[55,1993,1994,1996,1999,2001],{"class":57,"line":103},[55,1995,304],{"class":155},[55,1997,1998],{"class":159}," fastapi ",[55,2000,296],{"class":155},[55,2002,2003],{"class":159}," FastAPI\n",[55,2005,2006],{"class":57,"line":111},[55,2007,318],{"emptyLinePlaceholder":317},[55,2009,2010,2012,2015,2017],{"class":57,"line":119},[55,2011,304],{"class":155},[55,2013,2014],{"class":159}," usage.flusher ",[55,2016,296],{"class":155},[55,2018,2019],{"class":159}," flush_loop, usage_queue\n",[55,2021,2022,2024,2026,2028],{"class":57,"line":127},[55,2023,304],{"class":155},[55,2025,1551],{"class":159},[55,2027,296],{"class":155},[55,2029,2030],{"class":159}," UsageMiddleware\n",[55,2032,2033],{"class":57,"line":135},[55,2034,318],{"emptyLinePlaceholder":317},[55,2036,2037],{"class":57,"line":370},[55,2038,318],{"emptyLinePlaceholder":317},[55,2040,2041],{"class":57,"line":376},[55,2042,2043],{"class":61},"@asynccontextmanager\n",[55,2045,2046,2048,2050,2053],{"class":57,"line":381},[55,2047,1674],{"class":155},[55,2049,1144],{"class":155},[55,2051,2052],{"class":61}," lifespan",[55,2054,2055],{"class":159},"(app: FastAPI):\n",[55,2057,2058,2061,2063],{"class":57,"line":386},[55,2059,2060],{"class":159},"    task ",[55,2062,163],{"class":155},[55,2064,2065],{"class":159}," asyncio.create_task(flush_loop())\n",[55,2067,2068],{"class":57,"line":401},[55,2069,2070],{"class":155},"    yield\n",[55,2072,2073,2076,2079,2082,2085,2088],{"class":57,"line":412},[55,2074,2075],{"class":155},"    await",[55,2077,2078],{"class":159}," usage_queue.join() ",[55,2080,2081],{"class":155},"if",[55,2083,2084],{"class":159}," usage_queue.qsize() ",[55,2086,2087],{"class":155},"else",[55,2089,2090],{"class":65}," None\n",[55,2092,2093],{"class":57,"line":423},[55,2094,2095],{"class":159},"    task.cancel()\n",[55,2097,2098,2101],{"class":57,"line":446},[55,2099,2100],{"class":155},"    try",[55,2102,1243],{"class":159},[55,2104,2105,2108],{"class":57,"line":465},[55,2106,2107],{"class":155},"        await",[55,2109,2110],{"class":159}," task\n",[55,2112,2113,2116],{"class":57,"line":483},[55,2114,2115],{"class":155},"    except",[55,2117,2118],{"class":159}," asyncio.CancelledError:\n",[55,2120,2121],{"class":57,"line":489},[55,2122,2123],{"class":155},"        pass\n",[55,2125,2126],{"class":57,"line":494},[55,2127,318],{"emptyLinePlaceholder":317},[55,2129,2130],{"class":57,"line":535},[55,2131,318],{"emptyLinePlaceholder":317},[55,2133,2134,2137,2139,2142,2145,2147],{"class":57,"line":546},[55,2135,2136],{"class":159},"app ",[55,2138,163],{"class":155},[55,2140,2141],{"class":159}," FastAPI(",[55,2143,2144],{"class":514},"lifespan",[55,2146,163],{"class":155},[55,2148,2149],{"class":159},"lifespan)\n",[55,2151,2152],{"class":57,"line":579},[55,2153,2154],{"class":159},"app.add_middleware(UsageMiddleware)\n",[14,2156,2157,2158,779],{},"The engine here uses its own small pool rather than sharing the request pool. If the flusher and your handlers compete for the same twenty connections, a slow batch insert starves user traffic. The reasoning behind separate engines and pool sizing is covered in ",[18,2159,2161],{"href":2160},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F","async database access with SQLAlchemy",[27,2163,2165],{"id":2164},"step-4-enforce-quotas-with-atomic-redis-counters","Step 4: Enforce quotas with atomic Redis counters",[14,2167,2168],{},"Quota enforcement cannot wait for the flusher. A customer on a 50,000-call plan who buys 60,000 calls in a burst must be stopped at call 50,001, not two seconds later. Keep a separate counter in Redis, incremented on the hot path, and treat it as a cache of the durable event log rather than as the log itself.",[14,2170,2171,2172,2175,2176,2179],{},"Do the increment and the expiry in one Lua script. The naive ",[52,2173,2174],{},"INCR"," followed by ",[52,2177,2178],{},"EXPIRE"," has a real race: if the process dies between the two commands, the key never expires and the customer is permanently locked out of a billing period that ended weeks ago. One script, one round trip, no window.",[45,2181,2183],{"className":282,"code":2182,"language":62,"meta":50,"style":50},"# usage\u002Fquota.py\nimport os\nfrom datetime import datetime, timezone\n\nimport redis.asyncio as aioredis\n\nREDIS_URL = os.getenv(\"USAGE_REDIS_URL\", \"redis:\u002F\u002Flocalhost:6379\u002F0\")\nQUOTA_ENFORCED = os.getenv(\"USAGE_QUOTA_ENFORCED\", \"true\").lower() == \"true\"\n\n_INCR_WITH_TTL = \"\"\"\nlocal current = redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))\nif current == tonumber(ARGV[1]) then\n  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))\nend\nreturn current\n\"\"\"\n\nredis_client = aioredis.from_url(REDIS_URL, decode_responses=True)\n_script = redis_client.register_script(_INCR_WITH_TTL)\n\n# 40 days covers a full month plus late reconciliation.\nKEY_TTL_SECONDS = int(os.getenv(\"USAGE_QUOTA_TTL_SECONDS\", str(40 * 86400)))\n\n\ndef period_key(customer_id: str, now: datetime | None = None) -> str:\n    now = now or datetime.now(timezone.utc)\n    return f\"usage:{customer_id}:{now:%Y-%m}\"\n\n\nasync def consume(customer_id: str, units: int, limit: int) -> tuple[bool, int]:\n    \"\"\"Return (allowed, new_total). Fails open if Redis is unreachable.\"\"\"\n    key = period_key(customer_id)\n    try:\n        total = int(await _script(keys=[key], args=[units, KEY_TTL_SECONDS]))\n    except aioredis.RedisError:\n        return True, -1\n    if not QUOTA_ENFORCED:\n        return True, total\n    return total \u003C= limit, total\n",[52,2184,2185,2190,2196,2206,2210,2222,2226,2245,2271,2275,2285,2290,2295,2300,2305,2310,2315,2319,2342,2356,2360,2365,2396,2400,2404,2436,2452,2496,2500,2504,2539,2544,2554,2560,2597,2604,2617,2630,2639],{"__ignoreMap":50},[55,2186,2187],{"class":57,"line":58},[55,2188,2189],{"class":290},"# usage\u002Fquota.py\n",[55,2191,2192,2194],{"class":57,"line":79},[55,2193,296],{"class":155},[55,2195,299],{"class":159},[55,2197,2198,2200,2202,2204],{"class":57,"line":87},[55,2199,304],{"class":155},[55,2201,307],{"class":159},[55,2203,296],{"class":155},[55,2205,1013],{"class":159},[55,2207,2208],{"class":57,"line":95},[55,2209,318],{"emptyLinePlaceholder":317},[55,2211,2212,2214,2217,2219],{"class":57,"line":103},[55,2213,296],{"class":155},[55,2215,2216],{"class":159}," redis.asyncio ",[55,2218,1882],{"class":155},[55,2220,2221],{"class":159}," aioredis\n",[55,2223,2224],{"class":57,"line":111},[55,2225,318],{"emptyLinePlaceholder":317},[55,2227,2228,2231,2233,2235,2238,2240,2243],{"class":57,"line":119},[55,2229,2230],{"class":65},"REDIS_URL",[55,2232,735],{"class":155},[55,2234,738],{"class":159},[55,2236,2237],{"class":69},"\"USAGE_REDIS_URL\"",[55,2239,432],{"class":159},[55,2241,2242],{"class":69},"\"redis:\u002F\u002Flocalhost:6379\u002F0\"",[55,2244,532],{"class":159},[55,2246,2247,2250,2252,2254,2257,2259,2262,2265,2268],{"class":57,"line":127},[55,2248,2249],{"class":65},"QUOTA_ENFORCED",[55,2251,735],{"class":155},[55,2253,738],{"class":159},[55,2255,2256],{"class":69},"\"USAGE_QUOTA_ENFORCED\"",[55,2258,432],{"class":159},[55,2260,2261],{"class":69},"\"true\"",[55,2263,2264],{"class":159},").lower() ",[55,2266,2267],{"class":155},"==",[55,2269,2270],{"class":69}," \"true\"\n",[55,2272,2273],{"class":57,"line":135},[55,2274,318],{"emptyLinePlaceholder":317},[55,2276,2277,2280,2282],{"class":57,"line":370},[55,2278,2279],{"class":65},"_INCR_WITH_TTL",[55,2281,735],{"class":155},[55,2283,2284],{"class":69}," \"\"\"\n",[55,2286,2287],{"class":57,"line":376},[55,2288,2289],{"class":69},"local current = redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))\n",[55,2291,2292],{"class":57,"line":381},[55,2293,2294],{"class":69},"if current == tonumber(ARGV[1]) then\n",[55,2296,2297],{"class":57,"line":386},[55,2298,2299],{"class":69},"  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))\n",[55,2301,2302],{"class":57,"line":401},[55,2303,2304],{"class":69},"end\n",[55,2306,2307],{"class":57,"line":412},[55,2308,2309],{"class":69},"return current\n",[55,2311,2312],{"class":57,"line":423},[55,2313,2314],{"class":69},"\"\"\"\n",[55,2316,2317],{"class":57,"line":446},[55,2318,318],{"emptyLinePlaceholder":317},[55,2320,2321,2324,2326,2329,2331,2333,2336,2338,2340],{"class":57,"line":465},[55,2322,2323],{"class":159},"redis_client ",[55,2325,163],{"class":155},[55,2327,2328],{"class":159}," aioredis.from_url(",[55,2330,2230],{"class":65},[55,2332,432],{"class":159},[55,2334,2335],{"class":514},"decode_responses",[55,2337,163],{"class":155},[55,2339,520],{"class":65},[55,2341,532],{"class":159},[55,2343,2344,2347,2349,2352,2354],{"class":57,"line":483},[55,2345,2346],{"class":159},"_script ",[55,2348,163],{"class":155},[55,2350,2351],{"class":159}," redis_client.register_script(",[55,2353,2279],{"class":65},[55,2355,532],{"class":159},[55,2357,2358],{"class":57,"line":489},[55,2359,318],{"emptyLinePlaceholder":317},[55,2361,2362],{"class":57,"line":494},[55,2363,2364],{"class":290},"# 40 days covers a full month plus late reconciliation.\n",[55,2366,2367,2370,2372,2374,2376,2379,2381,2383,2385,2387,2390,2393],{"class":57,"line":535},[55,2368,2369],{"class":65},"KEY_TTL_SECONDS",[55,2371,735],{"class":155},[55,2373,1067],{"class":65},[55,2375,1070],{"class":159},[55,2377,2378],{"class":69},"\"USAGE_QUOTA_TTL_SECONDS\"",[55,2380,432],{"class":159},[55,2382,590],{"class":65},[55,2384,361],{"class":159},[55,2386,884],{"class":65},[55,2388,2389],{"class":155}," *",[55,2391,2392],{"class":65}," 86400",[55,2394,2395],{"class":159},")))\n",[55,2397,2398],{"class":57,"line":546},[55,2399,318],{"emptyLinePlaceholder":317},[55,2401,2402],{"class":57,"line":579},[55,2403,318],{"emptyLinePlaceholder":317},[55,2405,2406,2409,2412,2415,2417,2420,2423,2425,2427,2429,2432,2434],{"class":57,"line":584},[55,2407,2408],{"class":155},"def",[55,2410,2411],{"class":61}," period_key",[55,2413,2414],{"class":159},"(customer_id: ",[55,2416,590],{"class":65},[55,2418,2419],{"class":159},", now: datetime ",[55,2421,2422],{"class":155},"|",[55,2424,1240],{"class":65},[55,2426,735],{"class":155},[55,2428,1240],{"class":65},[55,2430,2431],{"class":159},") -> ",[55,2433,590],{"class":65},[55,2435,1243],{"class":159},[55,2437,2438,2441,2443,2446,2449],{"class":57,"line":606},[55,2439,2440],{"class":159},"    now ",[55,2442,163],{"class":155},[55,2444,2445],{"class":159}," now ",[55,2447,2448],{"class":155},"or",[55,2450,2451],{"class":159}," datetime.now(timezone.utc)\n",[55,2453,2454,2456,2459,2462,2465,2468,2471,2474,2476,2479,2482,2485,2488,2491,2493],{"class":57,"line":624},[55,2455,1814],{"class":155},[55,2457,2458],{"class":155}," f",[55,2460,2461],{"class":69},"\"usage:",[55,2463,2464],{"class":65},"{",[55,2466,2467],{"class":159},"customer_id",[55,2469,2470],{"class":65},"}",[55,2472,2473],{"class":69},":",[55,2475,2464],{"class":65},[55,2477,2478],{"class":159},"now:",[55,2480,2481],{"class":155},"%",[55,2483,2484],{"class":159},"Y",[55,2486,2487],{"class":155},"-%",[55,2489,2490],{"class":159},"m",[55,2492,2470],{"class":65},[55,2494,2495],{"class":69},"\"\n",[55,2497,2498],{"class":57,"line":643},[55,2499,318],{"emptyLinePlaceholder":317},[55,2501,2502],{"class":57,"line":662},[55,2503,318],{"emptyLinePlaceholder":317},[55,2505,2506,2508,2510,2513,2515,2517,2520,2522,2525,2527,2530,2533,2535,2537],{"class":57,"line":677},[55,2507,1674],{"class":155},[55,2509,1144],{"class":155},[55,2511,2512],{"class":61}," consume",[55,2514,2414],{"class":159},[55,2516,590],{"class":65},[55,2518,2519],{"class":159},", units: ",[55,2521,503],{"class":65},[55,2523,2524],{"class":159},", limit: ",[55,2526,503],{"class":65},[55,2528,2529],{"class":159},") -> tuple[",[55,2531,2532],{"class":65},"bool",[55,2534,432],{"class":159},[55,2536,503],{"class":65},[55,2538,1687],{"class":159},[55,2540,2541],{"class":57,"line":692},[55,2542,2543],{"class":69},"    \"\"\"Return (allowed, new_total). Fails open if Redis is unreachable.\"\"\"\n",[55,2545,2546,2549,2551],{"class":57,"line":719},[55,2547,2548],{"class":159},"    key ",[55,2550,163],{"class":155},[55,2552,2553],{"class":159}," period_key(customer_id)\n",[55,2555,2556,2558],{"class":57,"line":724},[55,2557,2100],{"class":155},[55,2559,1243],{"class":159},[55,2561,2562,2565,2567,2569,2571,2573,2576,2579,2581,2584,2587,2589,2592,2594],{"class":57,"line":729},[55,2563,2564],{"class":159},"        total ",[55,2566,163],{"class":155},[55,2568,1067],{"class":65},[55,2570,361],{"class":159},[55,2572,1793],{"class":155},[55,2574,2575],{"class":159}," _script(",[55,2577,2578],{"class":514},"keys",[55,2580,163],{"class":155},[55,2582,2583],{"class":159},"[key], ",[55,2585,2586],{"class":514},"args",[55,2588,163],{"class":155},[55,2590,2591],{"class":159},"[units, ",[55,2593,2369],{"class":65},[55,2595,2596],{"class":159},"]))\n",[55,2598,2599,2601],{"class":57,"line":1352},[55,2600,2115],{"class":155},[55,2602,2603],{"class":159}," aioredis.RedisError:\n",[55,2605,2606,2608,2610,2612,2614],{"class":57,"line":1374},[55,2607,1432],{"class":155},[55,2609,1848],{"class":65},[55,2611,432],{"class":159},[55,2613,1188],{"class":155},[55,2615,2616],{"class":65},"1\n",[55,2618,2619,2622,2625,2628],{"class":57,"line":1380},[55,2620,2621],{"class":155},"    if",[55,2623,2624],{"class":155}," not",[55,2626,2627],{"class":65}," QUOTA_ENFORCED",[55,2629,1243],{"class":159},[55,2631,2632,2634,2636],{"class":57,"line":1385},[55,2633,1432],{"class":155},[55,2635,1848],{"class":65},[55,2637,2638],{"class":159},", total\n",[55,2640,2641,2643,2646,2648],{"class":57,"line":1394},[55,2642,1814],{"class":155},[55,2644,2645],{"class":159}," total ",[55,2647,1769],{"class":155},[55,2649,2650],{"class":159}," limit, total\n",[14,2652,2653,2654,2658,2659,779],{},"Fail open, not closed. If Redis blinks, serving a few thousand over-quota calls costs you a rounding error; returning 429 to every paying customer costs you the account. The durable event log still records everything, so you can bill for the overage later even though you did not block it in the moment. That asymmetry is the whole argument for keeping enforcement and billing on separate substrates. For the harder abuse cases — burner emails, key sharing, distributed free-tier farming — see ",[18,2655,2657],{"href":2656},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fpreventing-free-tier-abuse\u002F","preventing free-tier abuse",", and for the response-shaping side of limits, ",[18,2660,2662],{"href":2661},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fbest-practices-for-api-rate-limiting\u002F","rate limiting best practices",[781,2664,790,2669,790,2672,790,2675,790,2678,790,2684,790,2690,790,2693,790,2696,790,2699,790,2703,790,2706,790,2710,790,2714,790,2716,790,2718,790,2720,790,2724,790,2729,790,2732,790,2737,790,2742,790,2746,790,2751,790,2754,790,2757,790,2760,790,2764,790,2769,790,2772],{"viewBox":2665,"role":784,"ariaLabelledBy":2666,"xmlns":788,"style":789},"0 0 760 350",[2667,2668],"tau-seq-t","tau-seq-d",[792,2670,2671],{"id":2667},"Quota check sequence for an over-limit request",[796,2673,2674],{"id":2668},"Sequence showing the client calling the middleware, the middleware running an atomic Redis increment, and branching to either a 429 response or the route handler depending on the returned counter.",[800,2676],{"x":802,"y":802,"width":803,"height":2677,"fill":805},"350",[800,2679],{"x":2680,"y":808,"width":2681,"height":2682,"rx":2683,"fill":812,"stroke":813,"style":814},"30","120","38","6",[816,2685,2689],{"x":2686,"y":2687,"fill":820,"style":2688},"90","44","text-anchor:middle;font-size:12;font-family:var(--font-sans);","Client",[800,2691],{"x":2692,"y":808,"width":810,"height":2682,"rx":2683,"fill":812,"stroke":833,"style":814},"220",[816,2694,2695],{"x":837,"y":2687,"fill":820,"style":2688},"Middleware",[800,2697],{"x":2698,"y":808,"width":855,"height":2682,"rx":2683,"fill":812,"stroke":878,"style":814},"440",[816,2700,2702],{"x":2701,"y":2687,"fill":820,"style":2688},"505","Redis",[800,2704],{"x":2705,"y":808,"width":2681,"height":2682,"rx":2683,"fill":812,"stroke":813,"style":814},"620",[816,2707,2709],{"x":2708,"y":2687,"fill":820,"style":2688},"680","Handler",[57,2711],{"x1":2686,"y1":859,"x2":2686,"y2":2712,"stroke":813,"style":2713},"326","stroke-width:1.5;stroke-dasharray:4 4;",[57,2715],{"x1":837,"y1":859,"x2":837,"y2":2712,"stroke":813,"style":2713},[57,2717],{"x1":2701,"y1":859,"x2":2701,"y2":2712,"stroke":813,"style":2713},[57,2719],{"x1":2708,"y1":859,"x2":2708,"y2":2712,"stroke":813,"style":2713},[57,2721],{"x1":2686,"y1":2722,"x2":2723,"y2":2722,"stroke":833,"style":834},"92","293",[816,2725,2728],{"x":2726,"y":2727,"fill":820,"style":827},"192","84","POST \u002Fv1\u002Fextract",[57,2730],{"x1":837,"y1":855,"x2":2731,"y2":855,"stroke":878,"style":834},"503",[816,2733,2736],{"x":2734,"y":2735,"fill":820,"style":827},"399","122","EVALSHA incr+expire",[57,2738],{"x1":2701,"y1":2739,"x2":2740,"y2":2739,"stroke":878,"style":2741},"166","297","stroke-width:2;stroke-dasharray:5 4;",[816,2743,2745],{"x":2734,"y":2744,"fill":820,"style":827},"158","total = 50001",[800,2747],{"x":898,"y":2748,"width":2698,"height":2749,"rx":2683,"fill":805,"stroke":2750,"style":814},"188","60","var(--c-yellow)",[816,2752,2753],{"x":844,"y":864,"fill":820,"style":827},"branch on total vs plan limit",[57,2755],{"x1":2723,"y1":2756,"x2":2722,"y2":2756,"stroke":826,"style":834},"230",[816,2758,2759],{"x":2726,"y":938,"fill":820,"style":827},"429 + Retry-After",[57,2761],{"x1":837,"y1":2762,"x2":2763,"y2":2762,"stroke":881,"style":834},"274","678",[816,2765,2768],{"x":2766,"y":2767,"fill":820,"style":827},"486","266","under limit: await call_next",[57,2770],{"x1":2708,"y1":2771,"x2":2740,"y2":2771,"stroke":881,"style":2741},"310",[816,2773,2775],{"x":2766,"y":2774,"fill":820,"style":827},"302","200 + billable_units",[27,2777,2779],{"id":2778},"step-5-roll-raw-events-into-aggregation-windows","Step 5: Roll raw events into aggregation windows",[14,2781,2782],{},"Raw events answer every question but answer them slowly. Once you hold 200 million rows, a dashboard query scanning a customer's month takes seconds and your dashboard becomes the heaviest workload on the database. Fix that with a two-level rollup: an hourly table written by a scheduled job, and a daily table derived from the hourly one.",[14,2784,2785],{},"Hourly granularity is the right first cut. It is coarse enough to shrink 3.6 million rows into a few thousand, and fine enough to show a customer the traffic spike that blew their quota at 14:00. Anything finer and you have not saved much; anything coarser and support tickets become guesswork.",[45,2787,2791],{"className":2788,"code":2789,"language":2790,"meta":50,"style":50},"language-sql shiki shiki-themes github-light github-dark","-- migrations\u002Fusage_rollup.sql\nCREATE TABLE IF NOT EXISTS usage_hourly (\n  bucket        timestamptz  NOT NULL,\n  customer_id   text         NOT NULL,\n  route         text         NOT NULL,\n  calls         bigint       NOT NULL,\n  billable      bigint       NOT NULL,\n  errors        bigint       NOT NULL,\n  duration_sum  bigint       NOT NULL,\n  PRIMARY KEY (bucket, customer_id, route)\n);\n\nINSERT INTO usage_hourly\nSELECT date_trunc('hour', occurred_at) AS bucket,\n       customer_id,\n       route,\n       count(*)                                        AS calls,\n       sum(billable_units)                             AS billable,\n       count(*) FILTER (WHERE status_code >= 500)      AS errors,\n       sum(duration_ms)                                AS duration_sum\nFROM usage_event\nWHERE occurred_at >= $1 AND occurred_at \u003C $2\nGROUP BY 1, 2, 3\nON CONFLICT (bucket, customer_id, route) DO UPDATE\nSET calls = EXCLUDED.calls,\n    billable = EXCLUDED.billable,\n    errors = EXCLUDED.errors,\n    duration_sum = EXCLUDED.duration_sum;\n","sql",[52,2792,2793,2798,2818,2832,2844,2856,2869,2880,2891,2902,2915,2920,2924,2932,2952,2957,2964,2981,2994,3031,3043,3051,3077,3094,3110,3130,3146,3162],{"__ignoreMap":50},[55,2794,2795],{"class":57,"line":58},[55,2796,2797],{"class":290},"-- migrations\u002Fusage_rollup.sql\n",[55,2799,2800,2803,2806,2809,2812,2815],{"class":57,"line":79},[55,2801,2802],{"class":155},"CREATE",[55,2804,2805],{"class":155}," TABLE",[55,2807,2808],{"class":61}," IF",[55,2810,2811],{"class":155}," NOT",[55,2813,2814],{"class":155}," EXISTS",[55,2816,2817],{"class":159}," usage_hourly (\n",[55,2819,2820,2823,2826,2829],{"class":57,"line":87},[55,2821,2822],{"class":159},"  bucket        ",[55,2824,2825],{"class":155},"timestamptz",[55,2827,2828],{"class":155},"  NOT NULL",[55,2830,2831],{"class":159},",\n",[55,2833,2834,2837,2839,2842],{"class":57,"line":95},[55,2835,2836],{"class":159},"  customer_id   ",[55,2838,816],{"class":155},[55,2840,2841],{"class":155},"         NOT NULL",[55,2843,2831],{"class":159},[55,2845,2846,2849,2852,2854],{"class":57,"line":103},[55,2847,2848],{"class":155},"  route",[55,2850,2851],{"class":155},"         text",[55,2853,2841],{"class":155},[55,2855,2831],{"class":159},[55,2857,2858,2861,2864,2867],{"class":57,"line":111},[55,2859,2860],{"class":159},"  calls         ",[55,2862,2863],{"class":155},"bigint",[55,2865,2866],{"class":155},"       NOT NULL",[55,2868,2831],{"class":159},[55,2870,2871,2874,2876,2878],{"class":57,"line":119},[55,2872,2873],{"class":159},"  billable      ",[55,2875,2863],{"class":155},[55,2877,2866],{"class":155},[55,2879,2831],{"class":159},[55,2881,2882,2885,2887,2889],{"class":57,"line":127},[55,2883,2884],{"class":159},"  errors        ",[55,2886,2863],{"class":155},[55,2888,2866],{"class":155},[55,2890,2831],{"class":159},[55,2892,2893,2896,2898,2900],{"class":57,"line":135},[55,2894,2895],{"class":159},"  duration_sum  ",[55,2897,2863],{"class":155},[55,2899,2866],{"class":155},[55,2901,2831],{"class":159},[55,2903,2904,2907,2910,2913],{"class":57,"line":370},[55,2905,2906],{"class":155},"  PRIMARY KEY",[55,2908,2909],{"class":159}," (bucket, customer_id, ",[55,2911,2912],{"class":155},"route",[55,2914,532],{"class":159},[55,2916,2917],{"class":57,"line":376},[55,2918,2919],{"class":159},");\n",[55,2921,2922],{"class":57,"line":381},[55,2923,318],{"emptyLinePlaceholder":317},[55,2925,2926,2929],{"class":57,"line":386},[55,2927,2928],{"class":155},"INSERT INTO",[55,2930,2931],{"class":159}," usage_hourly\n",[55,2933,2934,2937,2940,2943,2946,2949],{"class":57,"line":401},[55,2935,2936],{"class":155},"SELECT",[55,2938,2939],{"class":159}," date_trunc(",[55,2941,2942],{"class":69},"'hour'",[55,2944,2945],{"class":159},", occurred_at) ",[55,2947,2948],{"class":155},"AS",[55,2950,2951],{"class":159}," bucket,\n",[55,2953,2954],{"class":57,"line":412},[55,2955,2956],{"class":159},"       customer_id,\n",[55,2958,2959,2962],{"class":57,"line":423},[55,2960,2961],{"class":155},"       route",[55,2963,2831],{"class":159},[55,2965,2966,2969,2971,2973,2976,2978],{"class":57,"line":446},[55,2967,2968],{"class":65},"       count",[55,2970,361],{"class":159},[55,2972,1194],{"class":155},[55,2974,2975],{"class":159},")                                        ",[55,2977,2948],{"class":155},[55,2979,2980],{"class":159}," calls,\n",[55,2982,2983,2986,2989,2991],{"class":57,"line":465},[55,2984,2985],{"class":65},"       sum",[55,2987,2988],{"class":159},"(billable_units)                             ",[55,2990,2948],{"class":155},[55,2992,2993],{"class":159}," billable,\n",[55,2995,2996,2998,3000,3002,3005,3008,3011,3014,3017,3020,3023,3026,3028],{"class":57,"line":483},[55,2997,2968],{"class":65},[55,2999,361],{"class":159},[55,3001,1194],{"class":155},[55,3003,3004],{"class":159},") ",[55,3006,3007],{"class":155},"FILTER",[55,3009,3010],{"class":159}," (",[55,3012,3013],{"class":155},"WHERE",[55,3015,3016],{"class":159}," status_code ",[55,3018,3019],{"class":155},">=",[55,3021,3022],{"class":65}," 500",[55,3024,3025],{"class":159},")      ",[55,3027,2948],{"class":155},[55,3029,3030],{"class":159}," errors,\n",[55,3032,3033,3035,3038,3040],{"class":57,"line":489},[55,3034,2985],{"class":65},[55,3036,3037],{"class":159},"(duration_ms)                                ",[55,3039,2948],{"class":155},[55,3041,3042],{"class":159}," duration_sum\n",[55,3044,3045,3048],{"class":57,"line":494},[55,3046,3047],{"class":155},"FROM",[55,3049,3050],{"class":159}," usage_event\n",[55,3052,3053,3055,3058,3060,3063,3065,3068,3070,3072,3074],{"class":57,"line":535},[55,3054,3013],{"class":155},[55,3056,3057],{"class":159}," occurred_at ",[55,3059,3019],{"class":155},[55,3061,3062],{"class":159}," $",[55,3064,1369],{"class":65},[55,3066,3067],{"class":155}," AND",[55,3069,3057],{"class":159},[55,3071,1739],{"class":155},[55,3073,3062],{"class":159},[55,3075,3076],{"class":65},"2\n",[55,3078,3079,3082,3085,3087,3089,3091],{"class":57,"line":546},[55,3080,3081],{"class":155},"GROUP BY",[55,3083,3084],{"class":65}," 1",[55,3086,432],{"class":159},[55,3088,1639],{"class":65},[55,3090,432],{"class":159},[55,3092,3093],{"class":65},"3\n",[55,3095,3096,3099,3102,3104,3107],{"class":57,"line":579},[55,3097,3098],{"class":155},"ON",[55,3100,3101],{"class":159}," CONFLICT (bucket, customer_id, ",[55,3103,2912],{"class":155},[55,3105,3106],{"class":159},") DO ",[55,3108,3109],{"class":155},"UPDATE\n",[55,3111,3112,3115,3118,3120,3123,3125,3128],{"class":57,"line":584},[55,3113,3114],{"class":155},"SET",[55,3116,3117],{"class":159}," calls ",[55,3119,163],{"class":155},[55,3121,3122],{"class":65}," EXCLUDED",[55,3124,779],{"class":159},[55,3126,3127],{"class":65},"calls",[55,3129,2831],{"class":159},[55,3131,3132,3135,3137,3139,3141,3144],{"class":57,"line":606},[55,3133,3134],{"class":159},"    billable ",[55,3136,163],{"class":155},[55,3138,3122],{"class":65},[55,3140,779],{"class":159},[55,3142,3143],{"class":65},"billable",[55,3145,2831],{"class":159},[55,3147,3148,3151,3153,3155,3157,3160],{"class":57,"line":624},[55,3149,3150],{"class":159},"    errors ",[55,3152,163],{"class":155},[55,3154,3122],{"class":65},[55,3156,779],{"class":159},[55,3158,3159],{"class":65},"errors",[55,3161,2831],{"class":159},[55,3163,3164,3167,3169,3171,3173,3176],{"class":57,"line":643},[55,3165,3166],{"class":159},"    duration_sum ",[55,3168,163],{"class":155},[55,3170,3122],{"class":65},[55,3172,779],{"class":159},[55,3174,3175],{"class":65},"duration_sum",[55,3177,3178],{"class":159},";\n",[14,3180,3181,3182,3185,3186,3189],{},"The ",[52,3183,3184],{},"ON CONFLICT ... DO UPDATE"," makes the job idempotent, which matters because you will re-run it. Late events arrive: a flusher that retried through a database outage writes rows with an ",[52,3187,3188],{},"occurred_at"," from twenty minutes ago. Always re-aggregate a trailing window rather than only the last completed hour.",[45,3191,3193],{"className":282,"code":3192,"language":62,"meta":50,"style":50},"# usage\u002Frollup.py\nimport asyncio\nimport os\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom sqlalchemy import text\n\nfrom usage.flusher import Session\n\nLOOKBACK_HOURS = int(os.getenv(\"USAGE_ROLLUP_LOOKBACK_HOURS\", \"3\"))\nSQL_PATH = Path(os.getenv(\"USAGE_ROLLUP_SQL\", \"migrations\u002Fusage_rollup.sql\"))\n\n\nasync def rebuild_recent_hours() -> int:\n    now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)\n    start = now - timedelta(hours=LOOKBACK_HOURS)\n    statement = SQL_PATH.read_text().split(\";\", 1)[1]\n    async with Session() as session:\n        result = await session.execute(\n            text(statement.replace(\"$1\", \":start\").replace(\"$2\", \":end\")),\n            {\"start\": start, \"end\": now + timedelta(hours=1)},\n        )\n        await session.commit()\n    return result.rowcount\n\n\nif __name__ == \"__main__\":\n    print(asyncio.run(rebuild_recent_hours()), \"buckets rebuilt\")\n",[52,3194,3195,3200,3206,3212,3223,3235,3239,3250,3254,3265,3269,3290,3310,3314,3318,3333,3369,3392,3420,3432,3444,3471,3501,3506,3512,3519,3523,3527,3542],{"__ignoreMap":50},[55,3196,3197],{"class":57,"line":58},[55,3198,3199],{"class":290},"# usage\u002Frollup.py\n",[55,3201,3202,3204],{"class":57,"line":79},[55,3203,296],{"class":155},[55,3205,989],{"class":159},[55,3207,3208,3210],{"class":57,"line":87},[55,3209,296],{"class":155},[55,3211,299],{"class":159},[55,3213,3214,3216,3218,3220],{"class":57,"line":95},[55,3215,304],{"class":155},[55,3217,307],{"class":159},[55,3219,296],{"class":155},[55,3221,3222],{"class":159}," datetime, timedelta, timezone\n",[55,3224,3225,3227,3230,3232],{"class":57,"line":103},[55,3226,304],{"class":155},[55,3228,3229],{"class":159}," pathlib ",[55,3231,296],{"class":155},[55,3233,3234],{"class":159}," Path\n",[55,3236,3237],{"class":57,"line":111},[55,3238,318],{"emptyLinePlaceholder":317},[55,3240,3241,3243,3245,3247],{"class":57,"line":119},[55,3242,304],{"class":155},[55,3244,325],{"class":159},[55,3246,296],{"class":155},[55,3248,3249],{"class":159}," text\n",[55,3251,3252],{"class":57,"line":127},[55,3253,318],{"emptyLinePlaceholder":317},[55,3255,3256,3258,3260,3262],{"class":57,"line":135},[55,3257,304],{"class":155},[55,3259,2014],{"class":159},[55,3261,296],{"class":155},[55,3263,3264],{"class":159}," Session\n",[55,3266,3267],{"class":57,"line":370},[55,3268,318],{"emptyLinePlaceholder":317},[55,3270,3271,3274,3276,3278,3280,3283,3285,3288],{"class":57,"line":376},[55,3272,3273],{"class":65},"LOOKBACK_HOURS",[55,3275,735],{"class":155},[55,3277,1067],{"class":65},[55,3279,1070],{"class":159},[55,3281,3282],{"class":69},"\"USAGE_ROLLUP_LOOKBACK_HOURS\"",[55,3284,432],{"class":159},[55,3286,3287],{"class":69},"\"3\"",[55,3289,603],{"class":159},[55,3291,3292,3295,3297,3300,3303,3305,3308],{"class":57,"line":381},[55,3293,3294],{"class":65},"SQL_PATH",[55,3296,735],{"class":155},[55,3298,3299],{"class":159}," Path(os.getenv(",[55,3301,3302],{"class":69},"\"USAGE_ROLLUP_SQL\"",[55,3304,432],{"class":159},[55,3306,3307],{"class":69},"\"migrations\u002Fusage_rollup.sql\"",[55,3309,603],{"class":159},[55,3311,3312],{"class":57,"line":386},[55,3313,318],{"emptyLinePlaceholder":317},[55,3315,3316],{"class":57,"line":401},[55,3317,318],{"emptyLinePlaceholder":317},[55,3319,3320,3322,3324,3327,3329,3331],{"class":57,"line":412},[55,3321,1674],{"class":155},[55,3323,1144],{"class":155},[55,3325,3326],{"class":61}," rebuild_recent_hours",[55,3328,1837],{"class":159},[55,3330,503],{"class":65},[55,3332,1243],{"class":159},[55,3334,3335,3337,3339,3342,3345,3347,3349,3351,3354,3356,3358,3360,3363,3365,3367],{"class":57,"line":423},[55,3336,2440],{"class":159},[55,3338,163],{"class":155},[55,3340,3341],{"class":159}," datetime.now(timezone.utc).replace(",[55,3343,3344],{"class":514},"minute",[55,3346,163],{"class":155},[55,3348,802],{"class":65},[55,3350,432],{"class":159},[55,3352,3353],{"class":514},"second",[55,3355,163],{"class":155},[55,3357,802],{"class":65},[55,3359,432],{"class":159},[55,3361,3362],{"class":514},"microsecond",[55,3364,163],{"class":155},[55,3366,802],{"class":65},[55,3368,532],{"class":159},[55,3370,3371,3374,3376,3378,3380,3383,3386,3388,3390],{"class":57,"line":446},[55,3372,3373],{"class":159},"    start ",[55,3375,163],{"class":155},[55,3377,2445],{"class":159},[55,3379,1188],{"class":155},[55,3381,3382],{"class":159}," timedelta(",[55,3384,3385],{"class":514},"hours",[55,3387,163],{"class":155},[55,3389,3273],{"class":65},[55,3391,532],{"class":159},[55,3393,3394,3397,3399,3402,3405,3408,3410,3412,3415,3417],{"class":57,"line":465},[55,3395,3396],{"class":159},"    statement ",[55,3398,163],{"class":155},[55,3400,3401],{"class":65}," SQL_PATH",[55,3403,3404],{"class":159},".read_text().split(",[55,3406,3407],{"class":69},"\";\"",[55,3409,432],{"class":159},[55,3411,1369],{"class":65},[55,3413,3414],{"class":159},")[",[55,3416,1369],{"class":65},[55,3418,3419],{"class":159},"]\n",[55,3421,3422,3424,3426,3428,3430],{"class":57,"line":483},[55,3423,1141],{"class":155},[55,3425,1876],{"class":155},[55,3427,1879],{"class":159},[55,3429,1882],{"class":155},[55,3431,1885],{"class":159},[55,3433,3434,3437,3439,3441],{"class":57,"line":489},[55,3435,3436],{"class":159},"        result ",[55,3438,163],{"class":155},[55,3440,1170],{"class":155},[55,3442,3443],{"class":159}," session.execute(\n",[55,3445,3446,3449,3452,3454,3457,3460,3463,3465,3468],{"class":57,"line":494},[55,3447,3448],{"class":159},"            text(statement.replace(",[55,3450,3451],{"class":69},"\"$1\"",[55,3453,432],{"class":159},[55,3455,3456],{"class":69},"\":start\"",[55,3458,3459],{"class":159},").replace(",[55,3461,3462],{"class":69},"\"$2\"",[55,3464,432],{"class":159},[55,3466,3467],{"class":69},"\":end\"",[55,3469,3470],{"class":159},")),\n",[55,3472,3473,3476,3479,3482,3485,3488,3490,3492,3494,3496,3498],{"class":57,"line":535},[55,3474,3475],{"class":159},"            {",[55,3477,3478],{"class":69},"\"start\"",[55,3480,3481],{"class":159},": start, ",[55,3483,3484],{"class":69},"\"end\"",[55,3486,3487],{"class":159},": now ",[55,3489,1722],{"class":155},[55,3491,3382],{"class":159},[55,3493,3385],{"class":514},[55,3495,163],{"class":155},[55,3497,1369],{"class":65},[55,3499,3500],{"class":159},")},\n",[55,3502,3503],{"class":57,"line":546},[55,3504,3505],{"class":159},"        )\n",[55,3507,3508,3510],{"class":57,"line":579},[55,3509,2107],{"class":155},[55,3511,1900],{"class":159},[55,3513,3514,3516],{"class":57,"line":584},[55,3515,1814],{"class":155},[55,3517,3518],{"class":159}," result.rowcount\n",[55,3520,3521],{"class":57,"line":606},[55,3522,318],{"emptyLinePlaceholder":317},[55,3524,3525],{"class":57,"line":624},[55,3526,318],{"emptyLinePlaceholder":317},[55,3528,3529,3531,3534,3537,3540],{"class":57,"line":643},[55,3530,2081],{"class":155},[55,3532,3533],{"class":65}," __name__",[55,3535,3536],{"class":155}," ==",[55,3538,3539],{"class":69}," \"__main__\"",[55,3541,1243],{"class":159},[55,3543,3544,3547,3550,3553],{"class":57,"line":662},[55,3545,3546],{"class":65},"    print",[55,3548,3549],{"class":159},"(asyncio.run(rebuild_recent_hours()), ",[55,3551,3552],{"class":69},"\"buckets rebuilt\"",[55,3554,532],{"class":159},[14,3556,3557,3558,3562],{},"Run it every ten minutes from cron, or as a periodic task if you already operate ",[18,3559,3561],{"href":3560},"\u002Fscaling-and-operating-production-python-apis\u002Frunning-background-jobs-with-celery\u002F","Celery for background jobs",". Then set retention: drop raw partitions after 14 days, keep hourly for 13 months, keep daily forever. Thirteen months lets a customer compare this July to last July, which is the only comparison enterprise buyers ever ask for.",[781,3564,790,3569,790,3572,790,3575,790,3578,790,3583,790,3588,790,3593,790,3598,790,3602,790,3605,790,3608,790,3612,790,3616,790,3620,790,3623,790,3628,790,3631,790,3635,790,3638,790,3641],{"viewBox":3565,"role":784,"ariaLabelledBy":3566,"xmlns":788,"style":789},"0 0 760 300",[3567,3568],"tau-window-t","tau-window-d",[792,3570,3571],{"id":3567},"Aggregation windows and retention horizons",[796,3573,3574],{"id":3568},"Three retention bars showing raw events kept 14 days, hourly rollups kept 13 months, and daily rollups kept indefinitely, with row counts shrinking at each level.",[800,3576],{"x":802,"y":802,"width":803,"height":3577,"fill":805},"300",[816,3579,3582],{"x":2680,"y":3580,"fill":820,"style":3581},"34","font-size:13;font-family:var(--font-sans);","Retention horizon for one customer at 3.6M calls \u002F month",[816,3584,3587],{"x":2680,"y":3585,"fill":820,"style":3586},"76","font-size:12;font-family:var(--font-sans);","Raw events",[800,3589],{"x":3590,"y":2749,"width":2681,"height":3591,"rx":3592,"fill":812,"stroke":878,"style":834},"180","26","4",[816,3594,3597],{"x":3595,"y":3596,"fill":820,"style":827},"240","78","14 days",[816,3599,3601],{"x":942,"y":3596,"fill":826,"style":3600},"font-size:11;font-family:var(--font-sans);","1.7M rows, dropped by partition",[816,3603,3604],{"x":2680,"y":877,"fill":820,"style":3586},"Hourly rollup",[800,3606],{"x":3590,"y":3607,"width":804,"height":3591,"rx":3592,"fill":812,"stroke":833,"style":834},"124",[816,3609,3611],{"x":3610,"y":885,"fill":820,"style":827},"345","13 months",[816,3613,3615],{"x":3614,"y":885,"fill":826,"style":3600},"524","~57k rows",[816,3617,3619],{"x":2680,"y":3618,"fill":820,"style":3586},"204","Daily rollup",[800,3621],{"x":3590,"y":2748,"width":3622,"height":3591,"rx":3592,"fill":812,"stroke":881,"style":834},"540",[816,3624,3627],{"x":3625,"y":3626,"fill":820,"style":827},"450","206","kept indefinitely, ~2.4k rows",[57,3629],{"x1":3590,"y1":911,"x2":3630,"y2":911,"stroke":813,"style":814},"720",[816,3632,3634],{"x":3590,"y":3633,"fill":826,"style":827},"256","now",[816,3636,3637],{"x":3610,"y":3633,"fill":826,"style":827},"6 months ago",[816,3639,3640],{"x":3630,"y":3633,"fill":826,"style":827},"3 years ago",[816,3642,3644],{"x":2680,"y":3643,"fill":826,"style":3600},"284","Rebuild a 3-hour trailing window every run so late-flushed events land correctly.",[27,3646,3648],{"id":3647},"step-6-compute-revenue-and-margin-per-endpoint","Step 6: Compute revenue and margin per endpoint",[14,3650,3651],{},"This is the step most builders skip, and it is the one that changes decisions. Join your rollups against a cost model and a rate card, and you get gross margin per route. Almost every API business discovers at least one endpoint that loses money on every call — usually something that fans out to a paid third-party service or burns CPU on rendering.",[45,3653,3655],{"className":282,"code":3654,"language":62,"meta":50,"style":50},"# usage\u002Fmargin.py\nimport asyncio\nimport os\nimport tomllib\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\nfrom sqlalchemy import text\n\nfrom usage.flusher import Session\n\nRATE_CARD = Path(os.getenv(\"USAGE_RATE_CARD\", \"config\u002Frates.toml\"))\n\nMARGIN_SQL = \"\"\"\nSELECT route,\n       sum(calls)    AS calls,\n       sum(billable) AS billable,\n       sum(duration_sum)::float \u002F 1000 AS compute_seconds\nFROM usage_hourly\nWHERE bucket >= date_trunc('month', now())\nGROUP BY route\nORDER BY calls DESC\n\"\"\"\n\n\n@dataclass(frozen=True)\nclass RouteMargin:\n    route: str\n    revenue: float\n    cost: float\n\n    @property\n    def margin_pct(self) -> float:\n        return 0.0 if not self.revenue else (self.revenue - self.cost) \u002F self.revenue * 100\n\n\nasync def margin_by_route() -> list[RouteMargin]:\n    rates = tomllib.loads(RATE_CARD.read_text())\n    vcpu_second = float(rates[\"infra\"][\"usd_per_vcpu_second\"])\n    async with Session() as session:\n        rows = (await session.execute(text(MARGIN_SQL))).all()\n\n    out: list[RouteMargin] = []\n    for route, calls, billable, compute_seconds in rows:\n        card = rates[\"routes\"].get(route, {})\n        revenue = billable * float(card.get(\"usd_per_unit\", 0.0))\n        cost = compute_seconds * vcpu_second + calls * float(card.get(\"usd_vendor_per_call\", 0.0))\n        out.append(RouteMargin(route=route, revenue=revenue, cost=cost))\n    return sorted(out, key=lambda m: m.margin_pct)\n\n\nif __name__ == \"__main__\":\n    for m in asyncio.run(margin_by_route()):\n        print(f\"{m.route:\u003C34} rev ${m.revenue:>9,.2f}  cost ${m.cost:>9,.2f}  {m.margin_pct:>6.1f}%\")\n",[52,3656,3657,3662,3668,3674,3681,3693,3703,3707,3717,3721,3731,3735,3754,3758,3767,3772,3777,3782,3787,3792,3797,3802,3807,3811,3815,3819,3835,3844,3852,3860,3867,3871,3879,3895,3941,3945,3949,3961,3976,4000,4012,4031,4035,4045,4058,4074,4101,4136,4165,4185,4190,4195,4208,4221],{"__ignoreMap":50},[55,3658,3659],{"class":57,"line":58},[55,3660,3661],{"class":290},"# usage\u002Fmargin.py\n",[55,3663,3664,3666],{"class":57,"line":79},[55,3665,296],{"class":155},[55,3667,989],{"class":159},[55,3669,3670,3672],{"class":57,"line":87},[55,3671,296],{"class":155},[55,3673,299],{"class":159},[55,3675,3676,3678],{"class":57,"line":95},[55,3677,296],{"class":155},[55,3679,3680],{"class":159}," tomllib\n",[55,3682,3683,3685,3688,3690],{"class":57,"line":103},[55,3684,304],{"class":155},[55,3686,3687],{"class":159}," dataclasses ",[55,3689,296],{"class":155},[55,3691,3692],{"class":159}," dataclass\n",[55,3694,3695,3697,3699,3701],{"class":57,"line":111},[55,3696,304],{"class":155},[55,3698,3229],{"class":159},[55,3700,296],{"class":155},[55,3702,3234],{"class":159},[55,3704,3705],{"class":57,"line":119},[55,3706,318],{"emptyLinePlaceholder":317},[55,3708,3709,3711,3713,3715],{"class":57,"line":127},[55,3710,304],{"class":155},[55,3712,325],{"class":159},[55,3714,296],{"class":155},[55,3716,3249],{"class":159},[55,3718,3719],{"class":57,"line":135},[55,3720,318],{"emptyLinePlaceholder":317},[55,3722,3723,3725,3727,3729],{"class":57,"line":370},[55,3724,304],{"class":155},[55,3726,2014],{"class":159},[55,3728,296],{"class":155},[55,3730,3264],{"class":159},[55,3732,3733],{"class":57,"line":376},[55,3734,318],{"emptyLinePlaceholder":317},[55,3736,3737,3740,3742,3744,3747,3749,3752],{"class":57,"line":381},[55,3738,3739],{"class":65},"RATE_CARD",[55,3741,735],{"class":155},[55,3743,3299],{"class":159},[55,3745,3746],{"class":69},"\"USAGE_RATE_CARD\"",[55,3748,432],{"class":159},[55,3750,3751],{"class":69},"\"config\u002Frates.toml\"",[55,3753,603],{"class":159},[55,3755,3756],{"class":57,"line":386},[55,3757,318],{"emptyLinePlaceholder":317},[55,3759,3760,3763,3765],{"class":57,"line":401},[55,3761,3762],{"class":65},"MARGIN_SQL",[55,3764,735],{"class":155},[55,3766,2284],{"class":69},[55,3768,3769],{"class":57,"line":412},[55,3770,3771],{"class":69},"SELECT route,\n",[55,3773,3774],{"class":57,"line":423},[55,3775,3776],{"class":69},"       sum(calls)    AS calls,\n",[55,3778,3779],{"class":57,"line":446},[55,3780,3781],{"class":69},"       sum(billable) AS billable,\n",[55,3783,3784],{"class":57,"line":465},[55,3785,3786],{"class":69},"       sum(duration_sum)::float \u002F 1000 AS compute_seconds\n",[55,3788,3789],{"class":57,"line":483},[55,3790,3791],{"class":69},"FROM usage_hourly\n",[55,3793,3794],{"class":57,"line":489},[55,3795,3796],{"class":69},"WHERE bucket >= date_trunc('month', now())\n",[55,3798,3799],{"class":57,"line":494},[55,3800,3801],{"class":69},"GROUP BY route\n",[55,3803,3804],{"class":57,"line":535},[55,3805,3806],{"class":69},"ORDER BY calls DESC\n",[55,3808,3809],{"class":57,"line":546},[55,3810,2314],{"class":69},[55,3812,3813],{"class":57,"line":579},[55,3814,318],{"emptyLinePlaceholder":317},[55,3816,3817],{"class":57,"line":584},[55,3818,318],{"emptyLinePlaceholder":317},[55,3820,3821,3824,3826,3829,3831,3833],{"class":57,"line":606},[55,3822,3823],{"class":61},"@dataclass",[55,3825,361],{"class":159},[55,3827,3828],{"class":514},"frozen",[55,3830,163],{"class":155},[55,3832,520],{"class":65},[55,3834,532],{"class":159},[55,3836,3837,3839,3842],{"class":57,"line":624},[55,3838,355],{"class":155},[55,3840,3841],{"class":61}," RouteMargin",[55,3843,1243],{"class":159},[55,3845,3846,3849],{"class":57,"line":643},[55,3847,3848],{"class":159},"    route: ",[55,3850,3851],{"class":65},"str\n",[55,3853,3854,3857],{"class":57,"line":662},[55,3855,3856],{"class":159},"    revenue: ",[55,3858,3859],{"class":65},"float\n",[55,3861,3862,3865],{"class":57,"line":677},[55,3863,3864],{"class":159},"    cost: ",[55,3866,3859],{"class":65},[55,3868,3869],{"class":57,"line":692},[55,3870,318],{"emptyLinePlaceholder":317},[55,3872,3873,3876],{"class":57,"line":719},[55,3874,3875],{"class":61},"    @",[55,3877,3878],{"class":65},"property\n",[55,3880,3881,3884,3887,3890,3893],{"class":57,"line":724},[55,3882,3883],{"class":155},"    def",[55,3885,3886],{"class":61}," margin_pct",[55,3888,3889],{"class":159},"(self) -> ",[55,3891,3892],{"class":65},"float",[55,3894,1243],{"class":159},[55,3896,3897,3899,3902,3905,3907,3910,3913,3915,3917,3920,3922,3924,3926,3929,3932,3934,3936,3938],{"class":57,"line":729},[55,3898,1432],{"class":155},[55,3900,3901],{"class":65}," 0.0",[55,3903,3904],{"class":155}," if",[55,3906,2624],{"class":155},[55,3908,3909],{"class":65}," self",[55,3911,3912],{"class":159},".revenue ",[55,3914,2087],{"class":155},[55,3916,3010],{"class":159},[55,3918,3919],{"class":65},"self",[55,3921,3912],{"class":159},[55,3923,1188],{"class":155},[55,3925,3909],{"class":65},[55,3927,3928],{"class":159},".cost) ",[55,3930,3931],{"class":155},"\u002F",[55,3933,3909],{"class":65},[55,3935,3912],{"class":159},[55,3937,1194],{"class":155},[55,3939,3940],{"class":65}," 100\n",[55,3942,3943],{"class":57,"line":1352},[55,3944,318],{"emptyLinePlaceholder":317},[55,3946,3947],{"class":57,"line":1374},[55,3948,318],{"emptyLinePlaceholder":317},[55,3950,3951,3953,3955,3958],{"class":57,"line":1380},[55,3952,1674],{"class":155},[55,3954,1144],{"class":155},[55,3956,3957],{"class":61}," margin_by_route",[55,3959,3960],{"class":159},"() -> list[RouteMargin]:\n",[55,3962,3963,3966,3968,3971,3973],{"class":57,"line":1385},[55,3964,3965],{"class":159},"    rates ",[55,3967,163],{"class":155},[55,3969,3970],{"class":159}," tomllib.loads(",[55,3972,3739],{"class":65},[55,3974,3975],{"class":159},".read_text())\n",[55,3977,3978,3981,3983,3985,3988,3991,3994,3997],{"class":57,"line":1394},[55,3979,3980],{"class":159},"    vcpu_second ",[55,3982,163],{"class":155},[55,3984,1570],{"class":65},[55,3986,3987],{"class":159},"(rates[",[55,3989,3990],{"class":69},"\"infra\"",[55,3992,3993],{"class":159},"][",[55,3995,3996],{"class":69},"\"usd_per_vcpu_second\"",[55,3998,3999],{"class":159},"])\n",[55,4001,4002,4004,4006,4008,4010],{"class":57,"line":1402},[55,4003,1141],{"class":155},[55,4005,1876],{"class":155},[55,4007,1879],{"class":159},[55,4009,1882],{"class":155},[55,4011,1885],{"class":159},[55,4013,4014,4017,4019,4021,4023,4026,4028],{"class":57,"line":1408},[55,4015,4016],{"class":159},"        rows ",[55,4018,163],{"class":155},[55,4020,3010],{"class":159},[55,4022,1793],{"class":155},[55,4024,4025],{"class":159}," session.execute(text(",[55,4027,3762],{"class":65},[55,4029,4030],{"class":159},"))).all()\n",[55,4032,4033],{"class":57,"line":1417},[55,4034,318],{"emptyLinePlaceholder":317},[55,4036,4037,4040,4042],{"class":57,"line":1429},[55,4038,4039],{"class":159},"    out: list[RouteMargin] ",[55,4041,163],{"class":155},[55,4043,4044],{"class":159}," []\n",[55,4046,4047,4050,4053,4055],{"class":57,"line":1931},[55,4048,4049],{"class":155},"    for",[55,4051,4052],{"class":159}," route, calls, billable, compute_seconds ",[55,4054,1925],{"class":155},[55,4056,4057],{"class":159}," rows:\n",[55,4059,4060,4063,4065,4068,4071],{"class":57,"line":1940},[55,4061,4062],{"class":159},"        card ",[55,4064,163],{"class":155},[55,4066,4067],{"class":159}," rates[",[55,4069,4070],{"class":69},"\"routes\"",[55,4072,4073],{"class":159},"].get(route, {})\n",[55,4075,4076,4079,4081,4084,4086,4088,4091,4094,4096,4099],{"class":57,"line":1946},[55,4077,4078],{"class":159},"        revenue ",[55,4080,163],{"class":155},[55,4082,4083],{"class":159}," billable ",[55,4085,1194],{"class":155},[55,4087,1570],{"class":65},[55,4089,4090],{"class":159},"(card.get(",[55,4092,4093],{"class":69},"\"usd_per_unit\"",[55,4095,432],{"class":159},[55,4097,4098],{"class":65},"0.0",[55,4100,603],{"class":159},[55,4102,4104,4107,4109,4112,4114,4117,4119,4121,4123,4125,4127,4130,4132,4134],{"class":57,"line":4103},47,[55,4105,4106],{"class":159},"        cost ",[55,4108,163],{"class":155},[55,4110,4111],{"class":159}," compute_seconds ",[55,4113,1194],{"class":155},[55,4115,4116],{"class":159}," vcpu_second ",[55,4118,1722],{"class":155},[55,4120,3117],{"class":159},[55,4122,1194],{"class":155},[55,4124,1570],{"class":65},[55,4126,4090],{"class":159},[55,4128,4129],{"class":69},"\"usd_vendor_per_call\"",[55,4131,432],{"class":159},[55,4133,4098],{"class":65},[55,4135,603],{"class":159},[55,4137,4139,4142,4144,4146,4149,4152,4154,4157,4160,4162],{"class":57,"line":4138},48,[55,4140,4141],{"class":159},"        out.append(RouteMargin(",[55,4143,2912],{"class":514},[55,4145,163],{"class":155},[55,4147,4148],{"class":159},"route, ",[55,4150,4151],{"class":514},"revenue",[55,4153,163],{"class":155},[55,4155,4156],{"class":159},"revenue, ",[55,4158,4159],{"class":514},"cost",[55,4161,163],{"class":155},[55,4163,4164],{"class":159},"cost))\n",[55,4166,4168,4170,4173,4176,4179,4182],{"class":57,"line":4167},49,[55,4169,1814],{"class":155},[55,4171,4172],{"class":65}," sorted",[55,4174,4175],{"class":159},"(out, ",[55,4177,4178],{"class":514},"key",[55,4180,4181],{"class":155},"=lambda",[55,4183,4184],{"class":159}," m: m.margin_pct)\n",[55,4186,4188],{"class":57,"line":4187},50,[55,4189,318],{"emptyLinePlaceholder":317},[55,4191,4193],{"class":57,"line":4192},51,[55,4194,318],{"emptyLinePlaceholder":317},[55,4196,4198,4200,4202,4204,4206],{"class":57,"line":4197},52,[55,4199,2081],{"class":155},[55,4201,3533],{"class":65},[55,4203,3536],{"class":155},[55,4205,3539],{"class":69},[55,4207,1243],{"class":159},[55,4209,4211,4213,4216,4218],{"class":57,"line":4210},53,[55,4212,4049],{"class":155},[55,4214,4215],{"class":159}," m ",[55,4217,1925],{"class":155},[55,4219,4220],{"class":159}," asyncio.run(margin_by_route()):\n",[55,4222,4224,4227,4229,4232,4235,4237,4240,4243,4245,4248,4250,4253,4256,4258,4261,4263,4266,4268,4270,4273,4276,4279,4281,4284],{"class":57,"line":4223},54,[55,4225,4226],{"class":65},"        print",[55,4228,361],{"class":159},[55,4230,4231],{"class":155},"f",[55,4233,4234],{"class":69},"\"",[55,4236,2464],{"class":65},[55,4238,4239],{"class":159},"m.route",[55,4241,4242],{"class":155},":\u003C34",[55,4244,2470],{"class":65},[55,4246,4247],{"class":69}," rev $",[55,4249,2464],{"class":65},[55,4251,4252],{"class":159},"m.revenue",[55,4254,4255],{"class":155},":>9,.2f",[55,4257,2470],{"class":65},[55,4259,4260],{"class":69},"  cost $",[55,4262,2464],{"class":65},[55,4264,4265],{"class":159},"m.cost",[55,4267,4255],{"class":155},[55,4269,2470],{"class":65},[55,4271,4272],{"class":65},"  {",[55,4274,4275],{"class":159},"m.margin_pct",[55,4277,4278],{"class":155},":>6.1f",[55,4280,2470],{"class":65},[55,4282,4283],{"class":69},"%\"",[55,4285,532],{"class":159},[14,4287,4288,4289,4291,4292,4295,4296,779],{},"Sorting ascending by margin puts your worst endpoint first, which is exactly what you want to see every Monday morning. On one month of real numbers from a document API, the output looked like this: ",[52,4290,2728],{}," served 2.10M billable units for $2,940 in revenue against $882 of cost — a healthy 70% margin. ",[52,4293,4294],{},"POST \u002Fv1\u002Frender"," served 180,000 calls for $252 of revenue against $558 of cost, because each render fanned out to a paid headless browser service. That endpoint was losing 121% of its revenue on every call and had been shipped as a \"small convenience feature\". The methodology for building the cost side of that equation properly is in ",[18,4297,4299],{"href":4298},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fcalculating-cost-per-api-request\u002F","calculating cost per API request",[781,4301,790,4306,790,4309,790,4312,790,4315,790,4318,790,4322,790,4325,790,4327,790,4329,790,4332,790,4336,790,4340,790,4345,790,4349,790,4354,790,4358,790,4361,790,4366,790,4369,790,4372,790,4376,790,4380,790,4384,790,4387,790,4391],{"viewBox":4302,"role":784,"ariaLabelledBy":4303,"xmlns":788,"style":789},"0 0 760 320",[4304,4305],"tau-margin-t","tau-margin-d",[792,4307,4308],{"id":4304},"Monthly revenue against cost for three endpoints",[796,4310,4311],{"id":4305},"Paired bars comparing revenue and cost per endpoint: extract earns 2940 dollars against 882 of cost, status earns nothing against 64 of cost, and render earns 252 against 558 of cost.",[800,4313],{"x":802,"y":802,"width":803,"height":4314,"fill":805},"320",[816,4316,4317],{"x":2680,"y":2680,"fill":820,"style":3581},"Revenue vs cost by endpoint, one month (USD)",[800,4319],{"x":2680,"y":2687,"width":4320,"height":4320,"rx":4321,"fill":881},"14","3",[816,4323,4151],{"x":811,"y":4324,"fill":826,"style":3600},"56",[800,4326],{"x":2681,"y":2687,"width":4320,"height":4320,"rx":4321,"fill":878},[816,4328,4159],{"x":885,"y":4324,"fill":826,"style":3600},[57,4330],{"x1":898,"y1":876,"x2":898,"y2":4331,"stroke":813,"style":814},"272",[816,4333,4335],{"x":2680,"y":4334,"fill":820,"style":3586},"102","\u002Fv1\u002Fextract",[800,4337],{"x":898,"y":4338,"width":4339,"height":808,"rx":4321,"fill":881},"86","480",[816,4341,4344],{"x":4342,"y":4343,"fill":820,"style":3600},"692","101","2940",[800,4346],{"x":898,"y":4347,"width":4348,"height":808,"rx":4321,"fill":878},"110","144",[816,4350,4353],{"x":4351,"y":4352,"fill":820,"style":3600},"352","125","882",[816,4355,4357],{"x":2680,"y":4356,"fill":820,"style":3586},"172","\u002Fv1\u002Fstatus",[800,4359],{"x":898,"y":4360,"width":4321,"height":808,"rx":1369,"fill":881},"156",[816,4362,4365],{"x":4363,"y":4364,"fill":826,"style":3600},"212","171","0 (unbilled)",[800,4367],{"x":898,"y":3590,"width":4368,"height":808,"rx":4321,"fill":878},"11",[816,4370,600],{"x":2692,"y":4371,"fill":820,"style":3600},"195",[816,4373,4375],{"x":2680,"y":4374,"fill":820,"style":3586},"242","\u002Fv1\u002Frender",[800,4377],{"x":898,"y":4378,"width":4379,"height":808,"rx":4321,"fill":881},"226","41",[816,4381,4383],{"x":875,"y":4382,"fill":820,"style":3600},"241","252",[800,4385],{"x":898,"y":875,"width":4386,"height":808,"rx":4321,"fill":878},"91",[816,4388,4390],{"x":3577,"y":4389,"fill":820,"style":3600},"265","558",[816,4392,4394],{"x":2680,"y":4393,"fill":826,"style":3600},"298","\u002Fv1\u002Frender burns 2.2x its revenue: reprice it, cache it, or retire it.",[14,4396,4397,4398,4402],{},"Three options exist for a loss-making route, and only three: raise its price, cut its cost, or remove it. Delaying the decision is the same as choosing to subsidise it. Once you have this table, the arguments in ",[18,4399,4401],{"href":4400},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002F","designing API pricing tiers"," stop being theoretical, because you know exactly which unit you should be charging for.",[27,4404,4406],{"id":4405},"step-7-report-usage-into-stripe-billing","Step 7: Report usage into Stripe billing",[14,4408,4409,4410,4413],{},"The final hop pushes aggregated usage into your billing provider. Report once per hour from the durable rollup, never from the request path, and carry a deterministic idempotency key so a retried job cannot double-bill. Stripe's meter events accept an ",[52,4411,4412],{},"identifier","; build it from customer, meter, and bucket so the same hour can be submitted a hundred times and land once.",[45,4415,4417],{"className":282,"code":4416,"language":62,"meta":50,"style":50},"# usage\u002Fbilling_sync.py\nimport asyncio\nimport os\nfrom datetime import datetime, timezone\n\nimport stripe\nfrom sqlalchemy import text\n\nfrom usage.flusher import Session\n\nstripe.api_key = os.getenv(\"STRIPE_API_KEY\", \"\")\nMETER_EVENT_NAME = os.getenv(\"STRIPE_METER_EVENT_NAME\", \"api_requests\")\n\nPENDING_SQL = \"\"\"\nSELECT h.bucket, h.customer_id, c.stripe_customer_id, sum(h.billable) AS units\nFROM usage_hourly h\nJOIN customer c ON c.id = h.customer_id\nWHERE h.bucket >= :start AND h.bucket \u003C :end AND c.stripe_customer_id IS NOT NULL\nGROUP BY h.bucket, h.customer_id, c.stripe_customer_id\nHAVING sum(h.billable) > 0\n\"\"\"\n\n\nasync def report_hour(start: datetime, end: datetime) -> int:\n    async with Session() as session:\n        rows = (await session.execute(text(PENDING_SQL), {\"start\": start, \"end\": end})).all()\n\n    sent = 0\n    for bucket, customer_id, stripe_customer_id, units in rows:\n        identifier = f\"{METER_EVENT_NAME}:{customer_id}:{bucket:%Y%m%dT%H}\"\n        await asyncio.to_thread(\n            stripe.billing.MeterEvent.create,\n            event_name=METER_EVENT_NAME,\n            identifier=identifier,\n            timestamp=int(bucket.replace(tzinfo=timezone.utc).timestamp()),\n            payload={\"stripe_customer_id\": stripe_customer_id, \"value\": str(int(units))},\n        )\n        sent += 1\n    return sent\n",[52,4418,4419,4424,4430,4436,4446,4450,4457,4467,4471,4481,4485,4504,4523,4527,4536,4541,4546,4551,4556,4561,4566,4570,4574,4578,4594,4606,4632,4636,4645,4656,4707,4714,4719,4730,4740,4760,4789,4793,4802],{"__ignoreMap":50},[55,4420,4421],{"class":57,"line":58},[55,4422,4423],{"class":290},"# usage\u002Fbilling_sync.py\n",[55,4425,4426,4428],{"class":57,"line":79},[55,4427,296],{"class":155},[55,4429,989],{"class":159},[55,4431,4432,4434],{"class":57,"line":87},[55,4433,296],{"class":155},[55,4435,299],{"class":159},[55,4437,4438,4440,4442,4444],{"class":57,"line":95},[55,4439,304],{"class":155},[55,4441,307],{"class":159},[55,4443,296],{"class":155},[55,4445,1013],{"class":159},[55,4447,4448],{"class":57,"line":103},[55,4449,318],{"emptyLinePlaceholder":317},[55,4451,4452,4454],{"class":57,"line":111},[55,4453,296],{"class":155},[55,4455,4456],{"class":159}," stripe\n",[55,4458,4459,4461,4463,4465],{"class":57,"line":119},[55,4460,304],{"class":155},[55,4462,325],{"class":159},[55,4464,296],{"class":155},[55,4466,3249],{"class":159},[55,4468,4469],{"class":57,"line":127},[55,4470,318],{"emptyLinePlaceholder":317},[55,4472,4473,4475,4477,4479],{"class":57,"line":135},[55,4474,304],{"class":155},[55,4476,2014],{"class":159},[55,4478,296],{"class":155},[55,4480,3264],{"class":159},[55,4482,4483],{"class":57,"line":370},[55,4484,318],{"emptyLinePlaceholder":317},[55,4486,4487,4490,4492,4494,4497,4499,4502],{"class":57,"line":376},[55,4488,4489],{"class":159},"stripe.api_key ",[55,4491,163],{"class":155},[55,4493,738],{"class":159},[55,4495,4496],{"class":69},"\"STRIPE_API_KEY\"",[55,4498,432],{"class":159},[55,4500,4501],{"class":69},"\"\"",[55,4503,532],{"class":159},[55,4505,4506,4509,4511,4513,4516,4518,4521],{"class":57,"line":381},[55,4507,4508],{"class":65},"METER_EVENT_NAME",[55,4510,735],{"class":155},[55,4512,738],{"class":159},[55,4514,4515],{"class":69},"\"STRIPE_METER_EVENT_NAME\"",[55,4517,432],{"class":159},[55,4519,4520],{"class":69},"\"api_requests\"",[55,4522,532],{"class":159},[55,4524,4525],{"class":57,"line":386},[55,4526,318],{"emptyLinePlaceholder":317},[55,4528,4529,4532,4534],{"class":57,"line":401},[55,4530,4531],{"class":65},"PENDING_SQL",[55,4533,735],{"class":155},[55,4535,2284],{"class":69},[55,4537,4538],{"class":57,"line":412},[55,4539,4540],{"class":69},"SELECT h.bucket, h.customer_id, c.stripe_customer_id, sum(h.billable) AS units\n",[55,4542,4543],{"class":57,"line":423},[55,4544,4545],{"class":69},"FROM usage_hourly h\n",[55,4547,4548],{"class":57,"line":446},[55,4549,4550],{"class":69},"JOIN customer c ON c.id = h.customer_id\n",[55,4552,4553],{"class":57,"line":465},[55,4554,4555],{"class":69},"WHERE h.bucket >= :start AND h.bucket \u003C :end AND c.stripe_customer_id IS NOT NULL\n",[55,4557,4558],{"class":57,"line":483},[55,4559,4560],{"class":69},"GROUP BY h.bucket, h.customer_id, c.stripe_customer_id\n",[55,4562,4563],{"class":57,"line":489},[55,4564,4565],{"class":69},"HAVING sum(h.billable) > 0\n",[55,4567,4568],{"class":57,"line":494},[55,4569,2314],{"class":69},[55,4571,4572],{"class":57,"line":535},[55,4573,318],{"emptyLinePlaceholder":317},[55,4575,4576],{"class":57,"line":546},[55,4577,318],{"emptyLinePlaceholder":317},[55,4579,4580,4582,4584,4587,4590,4592],{"class":57,"line":579},[55,4581,1674],{"class":155},[55,4583,1144],{"class":155},[55,4585,4586],{"class":61}," report_hour",[55,4588,4589],{"class":159},"(start: datetime, end: datetime) -> ",[55,4591,503],{"class":65},[55,4593,1243],{"class":159},[55,4595,4596,4598,4600,4602,4604],{"class":57,"line":584},[55,4597,1141],{"class":155},[55,4599,1876],{"class":155},[55,4601,1879],{"class":159},[55,4603,1882],{"class":155},[55,4605,1885],{"class":159},[55,4607,4608,4610,4612,4614,4616,4618,4620,4623,4625,4627,4629],{"class":57,"line":606},[55,4609,4016],{"class":159},[55,4611,163],{"class":155},[55,4613,3010],{"class":159},[55,4615,1793],{"class":155},[55,4617,4025],{"class":159},[55,4619,4531],{"class":65},[55,4621,4622],{"class":159},"), {",[55,4624,3478],{"class":69},[55,4626,3481],{"class":159},[55,4628,3484],{"class":69},[55,4630,4631],{"class":159},": end})).all()\n",[55,4633,4634],{"class":57,"line":624},[55,4635,318],{"emptyLinePlaceholder":317},[55,4637,4638,4641,4643],{"class":57,"line":643},[55,4639,4640],{"class":159},"    sent ",[55,4642,163],{"class":155},[55,4644,1114],{"class":65},[55,4646,4647,4649,4652,4654],{"class":57,"line":662},[55,4648,4049],{"class":155},[55,4650,4651],{"class":159}," bucket, customer_id, stripe_customer_id, units ",[55,4653,1925],{"class":155},[55,4655,4057],{"class":159},[55,4657,4658,4661,4663,4665,4667,4670,4672,4674,4676,4678,4680,4682,4685,4687,4689,4691,4693,4695,4698,4700,4703,4705],{"class":57,"line":677},[55,4659,4660],{"class":159},"        identifier ",[55,4662,163],{"class":155},[55,4664,2458],{"class":155},[55,4666,4234],{"class":69},[55,4668,4669],{"class":65},"{METER_EVENT_NAME}",[55,4671,2473],{"class":69},[55,4673,2464],{"class":65},[55,4675,2467],{"class":159},[55,4677,2470],{"class":65},[55,4679,2473],{"class":69},[55,4681,2464],{"class":65},[55,4683,4684],{"class":159},"bucket:",[55,4686,2481],{"class":155},[55,4688,2484],{"class":159},[55,4690,2481],{"class":155},[55,4692,2490],{"class":159},[55,4694,2481],{"class":155},[55,4696,4697],{"class":159},"dT",[55,4699,2481],{"class":155},[55,4701,4702],{"class":159},"H",[55,4704,2470],{"class":65},[55,4706,2495],{"class":69},[55,4708,4709,4711],{"class":57,"line":692},[55,4710,2107],{"class":155},[55,4712,4713],{"class":159}," asyncio.to_thread(\n",[55,4715,4716],{"class":57,"line":719},[55,4717,4718],{"class":159},"            stripe.billing.MeterEvent.create,\n",[55,4720,4721,4724,4726,4728],{"class":57,"line":724},[55,4722,4723],{"class":514},"            event_name",[55,4725,163],{"class":155},[55,4727,4508],{"class":65},[55,4729,2831],{"class":159},[55,4731,4732,4735,4737],{"class":57,"line":729},[55,4733,4734],{"class":514},"            identifier",[55,4736,163],{"class":155},[55,4738,4739],{"class":159},"identifier,\n",[55,4741,4742,4745,4747,4749,4752,4755,4757],{"class":57,"line":1352},[55,4743,4744],{"class":514},"            timestamp",[55,4746,163],{"class":155},[55,4748,503],{"class":65},[55,4750,4751],{"class":159},"(bucket.replace(",[55,4753,4754],{"class":514},"tzinfo",[55,4756,163],{"class":155},[55,4758,4759],{"class":159},"timezone.utc).timestamp()),\n",[55,4761,4762,4765,4767,4769,4772,4775,4778,4780,4782,4784,4786],{"class":57,"line":1374},[55,4763,4764],{"class":514},"            payload",[55,4766,163],{"class":155},[55,4768,2464],{"class":159},[55,4770,4771],{"class":69},"\"stripe_customer_id\"",[55,4773,4774],{"class":159},": stripe_customer_id, ",[55,4776,4777],{"class":69},"\"value\"",[55,4779,474],{"class":159},[55,4781,590],{"class":65},[55,4783,361],{"class":159},[55,4785,503],{"class":65},[55,4787,4788],{"class":159},"(units))},\n",[55,4790,4791],{"class":57,"line":1380},[55,4792,3505],{"class":159},[55,4794,4795,4798,4800],{"class":57,"line":1385},[55,4796,4797],{"class":159},"        sent ",[55,4799,1423],{"class":155},[55,4801,1426],{"class":65},[55,4803,4804,4806],{"class":57,"line":1394},[55,4805,1814],{"class":155},[55,4807,4808],{"class":159}," sent\n",[14,4810,4811,4814,4815,4819,4820,779],{},[52,4812,4813],{},"asyncio.to_thread"," keeps the synchronous Stripe SDK from blocking the event loop. It is not elegant, but it is correct and it ships today; the alternative is hand-rolling the REST calls with httpx and re-implementing Stripe's retry semantics for no commercial gain. Meter configuration, aggregation formulas, and the subscription wiring are covered in ",[18,4816,4818],{"href":4817},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fstripe-metered-billing-configuration\u002F","Stripe metered billing configuration",", and the broader payment integration in ",[18,4821,4823],{"href":4822},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fintegrating-stripe-with-python-apis\u002F","integrating Stripe with Python APIs",[14,4825,4826,4827,4830],{},"Reconcile monthly. Before the invoice finalises, compare your ",[52,4828,4829],{},"usage_hourly"," sum against what Stripe recorded for the period. A drift of more than 0.1% means events were lost or double-sent, and you want to find that on the 28th, not in a support thread on the 3rd.",[781,4832,790,4836,790,4839,790,4842,790,4844,790,4848,790,4852,790,4854,790,4858,790,4861,790,4865,790,4868,790,4872,790,4875,790,4878,790,4880,790,4884,790,4887,790,4890,790,4894,790,4897,790,4900,790,4903,790,4906,790,4909,790,4913,790,4916,790,4919],{"viewBox":3565,"role":784,"ariaLabelledBy":4833,"xmlns":788,"style":789},[4834,4835],"tau-state-t","tau-state-d",[792,4837,4838],{"id":4834},"Lifecycle of a usage event from buffer to invoice",[796,4840,4841],{"id":4835},"State machine showing an event moving from buffered to persisted to aggregated to reported to invoiced, with a retry loop on flush failure and a backfill edge from reconciliation.",[800,4843],{"x":802,"y":802,"width":803,"height":3577,"fill":805},[800,4845],{"x":4846,"y":2749,"width":4847,"height":819,"rx":657,"fill":812,"stroke":881,"style":834},"24","126",[816,4849,4851],{"x":4850,"y":2686,"fill":820,"style":2688},"87","buffered",[800,4853],{"x":886,"y":2749,"width":4847,"height":819,"rx":657,"fill":812,"stroke":881,"style":834},[816,4855,4857],{"x":4856,"y":2686,"fill":820,"style":2688},"253","persisted",[800,4859],{"x":4860,"y":2749,"width":4847,"height":819,"rx":657,"fill":812,"stroke":833,"style":834},"356",[816,4862,4864],{"x":4863,"y":2686,"fill":820,"style":2688},"419","aggregated",[800,4866],{"x":4867,"y":2749,"width":4847,"height":819,"rx":657,"fill":812,"stroke":833,"style":834},"522",[816,4869,4871],{"x":4870,"y":2686,"fill":820,"style":2688},"585","reported",[800,4873],{"x":4860,"y":4874,"width":4847,"height":819,"rx":657,"fill":812,"stroke":2750,"style":834},"196",[816,4876,4877],{"x":4863,"y":4378,"fill":820,"style":2688},"invoiced",[800,4879],{"x":855,"y":4874,"width":810,"height":819,"rx":657,"fill":812,"stroke":878,"style":834},[816,4881,4883],{"x":4882,"y":4378,"fill":820,"style":2688},"205","reconcile drift",[57,4885],{"x1":810,"y1":4886,"x2":2748,"y2":4886,"stroke":826,"style":834},"85",[57,4888],{"x1":942,"y1":4886,"x2":4889,"y2":4886,"stroke":826,"style":834},"354",[57,4891],{"x1":4892,"y1":4886,"x2":4893,"y2":4886,"stroke":826,"style":834},"482","520",[57,4895],{"x1":4870,"y1":4347,"x2":4870,"y2":4896,"stroke":826,"style":834},"221",[57,4898],{"x1":4870,"y1":4896,"x2":4899,"y2":4896,"stroke":826,"style":834},"484",[57,4901],{"x1":4889,"y1":4896,"x2":4902,"y2":4896,"stroke":878,"style":2741},"282",[57,4904],{"x1":4882,"y1":4874,"x2":4882,"y2":4905,"stroke":878,"style":2741},"112",[816,4907,4908],{"x":4856,"y":877,"fill":826,"style":827},"backfill",[816,4910,4912],{"x":4850,"y":4911,"fill":826,"style":827},"42","may be dropped",[816,4914,4915],{"x":4863,"y":4911,"fill":826,"style":827},"idempotent rebuild",[816,4917,4918],{"x":4870,"y":4911,"fill":826,"style":827},"keyed identifier",[816,4920,4923],{"x":4921,"y":4922,"fill":826,"style":827},"318","278","Only the persisted state is authoritative for money.",[27,4925,4927],{"id":4926},"configuration-reference","Configuration reference",[4929,4930,4931,4947],"table",{},[4932,4933,4934],"thead",{},[4935,4936,4937,4941,4944],"tr",{},[4938,4939,4940],"th",{},"Env var",[4938,4942,4943],{},"Default",[4938,4945,4946],{},"Production recommendation",[4948,4949,4950,4964,4979,4999,5018,5033,5054,5069,5085],"tbody",{},[4935,4951,4952,4958,4961],{},[4953,4954,4955],"td",{},[52,4956,4957],{},"USAGE_DATABASE_URL",[4953,4959,4960],{},"none",[4953,4962,4963],{},"Dedicated asyncpg URL, separate pool",[4935,4965,4966,4971,4976],{},[4953,4967,4968],{},[52,4969,4970],{},"USAGE_REDIS_URL",[4953,4972,4973],{},[52,4974,4975],{},"redis:\u002F\u002Flocalhost:6379\u002F0",[4953,4977,4978],{},"Own logical db, no eviction policy",[4935,4980,4981,4985,4990],{},[4953,4982,4983],{},[52,4984,1467],{},[4953,4986,4987],{},[52,4988,4989],{},"2.0",[4953,4991,4992,4995,4996,4998],{},[52,4993,4994],{},"1.0","–",[52,4997,4989],{},"; lower loses less on crash",[4935,5000,5001,5005,5010],{},[4953,5002,5003],{},[52,5004,1463],{},[4953,5006,5007],{},[52,5008,5009],{},"500",[4953,5011,5012,4995,5014,5017],{},[52,5013,5009],{},[52,5015,5016],{},"1000"," rows per insert",[4935,5019,5020,5025,5030],{},[4953,5021,5022],{},[52,5023,5024],{},"USAGE_QUEUE_MAXSIZE",[4953,5026,5027],{},[52,5028,5029],{},"20000",[4953,5031,5032],{},"30x peak RPS per worker",[4935,5034,5035,5040,5045],{},[4953,5036,5037],{},[52,5038,5039],{},"USAGE_QUOTA_ENFORCED",[4953,5041,5042],{},[52,5043,5044],{},"true",[4953,5046,5047,5049,5050,5053],{},[52,5048,5044],{}," in prod, ",[52,5051,5052],{},"false"," in staging",[4935,5055,5056,5061,5066],{},[4953,5057,5058],{},[52,5059,5060],{},"USAGE_QUOTA_TTL_SECONDS",[4953,5062,5063],{},[52,5064,5065],{},"3456000",[4953,5067,5068],{},"40 days, covers late reconciliation",[4935,5070,5071,5076,5080],{},[4953,5072,5073],{},[52,5074,5075],{},"USAGE_ROLLUP_LOOKBACK_HOURS",[4953,5077,5078],{},[52,5079,4321],{},[4953,5081,5082,5084],{},[52,5083,4321],{},"; raise if flush retries run long",[4935,5086,5087,5092,5097],{},[4953,5088,5089],{},[52,5090,5091],{},"STRIPE_METER_EVENT_NAME",[4953,5093,5094],{},[52,5095,5096],{},"api_requests",[4953,5098,5099],{},"Match the meter you created in Stripe",[14,5101,5102,5103,5105],{},"Set ",[52,5104,5024],{}," from measured traffic, not intuition. At 200 requests per second per worker with a 2-second flush interval, steady state holds 400 events; a 20,000 slot buffer absorbs a 100-second database outage before it starts dropping. Redis must never run with an LRU eviction policy on the quota database, because evicting a counter silently resets a customer's consumption to zero mid-month.",[27,5107,5109],{"id":5108},"gotchas-and-failure-modes","Gotchas and failure modes",[14,5111,5112,5116,5117,5120],{},[5113,5114,5115],"strong",{},"Counting requests when you should count units."," Symptom: revenue stays flat while infrastructure cost climbs, because heavy customers batch 200 items into one call. Fix: set ",[52,5118,5119],{},"request.state.billable_units"," in the handler based on actual work done, and price the unit rather than the call. Ship this before you have customers; changing the billable unit after launch requires grandfathering every existing plan.",[14,5122,5123,5126,5127,5130],{},[5113,5124,5125],{},"Dropping the buffer on deploy."," Symptom: usage totals dip predictably at deploy time, always slightly under. Fix: drain the queue in the lifespan shutdown path and give your platform a ",[52,5128,5129],{},"terminationGracePeriod"," longer than the flush interval. On Render or Fly this is a single setting, and skipping it costs a few tenths of a percent of revenue per deploy — small until you deploy forty times a month.",[14,5132,5133,5136,5137,5140,5141,5143],{},[5113,5134,5135],{},"Trusting Redis for billing."," Symptom: a Redis failover, a ",[52,5138,5139],{},"FLUSHDB"," in the wrong shell, or a maxmemory eviction, and a customer's month reads zero. Fix: Redis decides enforcement in real time, Postgres decides money. Rehydrate the counter from ",[52,5142,4829],{}," on a cache miss instead of assuming zero, otherwise a restart hands every customer a fresh quota.",[14,5145,5146,5149,5150,5152,5153,5155,5156,5159],{},[5113,5147,5148],{},"Unbounded route cardinality."," Symptom: ",[52,5151,4829],{}," grows as fast as ",[52,5154,932],{}," and the rollup stops helping. Fix: always aggregate on the route template, and if the template is missing — a 404, a proxy passthrough — write the literal string ",[52,5157,5158],{},"unmatched",". One bad path parameter leaking into the route column can add millions of distinct values in a week.",[14,5161,5162,5165,5166,5169,5170,779],{},[5113,5163,5164],{},"Timezone drift at the period boundary."," Symptom: a customer's usage looks 4% high or low in the first days of a month, and disputes pile up on the 1st. Fix: store and bucket everything in UTC, then convert only at the presentation layer. If your Stripe subscription anchors to a non-UTC billing cycle, align the rollup query to the subscription's ",[52,5167,5168],{},"current_period_start"," rather than to ",[52,5171,5172],{},"date_trunc('month')",[27,5174,5176],{"id":5175},"verification","Verification",[14,5178,5179],{},"Prove the pipeline end to end before you point billing at it. Make an authenticated call, then confirm the event landed and the counter moved.",[45,5181,5183],{"className":47,"code":5182,"language":49,"meta":50,"style":50},"curl -s -o \u002Fdev\u002Fnull -w '%{http_code}\\n' \\\n  -H \"Authorization: Bearer $TEST_API_KEY\" \\\n  \"$API_BASE_URL\u002Fv1\u002Fextract\" -d '{\"pages\": 3}' -H 'content-type: application\u002Fjson'\n\nsleep 3\npsql \"$PGURL\" -c \\\n  \"SELECT route, billable_units, status_code FROM usage_event ORDER BY occurred_at DESC LIMIT 1;\"\n\nredis-cli -u \"$USAGE_REDIS_URL\" GET \"usage:cus_test123:$(date -u +%Y-%m)\"\n",[52,5184,5185,5207,5222,5245,5249,5257,5275,5280,5284],{"__ignoreMap":50},[55,5186,5187,5190,5193,5196,5199,5202,5205],{"class":57,"line":58},[55,5188,5189],{"class":61},"curl",[55,5191,5192],{"class":65}," -s",[55,5194,5195],{"class":65}," -o",[55,5197,5198],{"class":69}," \u002Fdev\u002Fnull",[55,5200,5201],{"class":65}," -w",[55,5203,5204],{"class":69}," '%{http_code}\\n'",[55,5206,76],{"class":65},[55,5208,5209,5212,5215,5218,5220],{"class":57,"line":79},[55,5210,5211],{"class":65},"  -H",[55,5213,5214],{"class":69}," \"Authorization: Bearer ",[55,5216,5217],{"class":159},"$TEST_API_KEY",[55,5219,4234],{"class":69},[55,5221,76],{"class":65},[55,5223,5224,5227,5230,5233,5236,5239,5242],{"class":57,"line":87},[55,5225,5226],{"class":69},"  \"",[55,5228,5229],{"class":159},"$API_BASE_URL",[55,5231,5232],{"class":69},"\u002Fv1\u002Fextract\"",[55,5234,5235],{"class":65}," -d",[55,5237,5238],{"class":69}," '{\"pages\": 3}'",[55,5240,5241],{"class":65}," -H",[55,5243,5244],{"class":69}," 'content-type: application\u002Fjson'\n",[55,5246,5247],{"class":57,"line":95},[55,5248,318],{"emptyLinePlaceholder":317},[55,5250,5251,5254],{"class":57,"line":103},[55,5252,5253],{"class":61},"sleep",[55,5255,5256],{"class":65}," 3\n",[55,5258,5259,5262,5265,5268,5270,5273],{"class":57,"line":111},[55,5260,5261],{"class":61},"psql",[55,5263,5264],{"class":69}," \"",[55,5266,5267],{"class":159},"$PGURL",[55,5269,4234],{"class":69},[55,5271,5272],{"class":65}," -c",[55,5274,76],{"class":65},[55,5276,5277],{"class":57,"line":119},[55,5278,5279],{"class":69},"  \"SELECT route, billable_units, status_code FROM usage_event ORDER BY occurred_at DESC LIMIT 1;\"\n",[55,5281,5282],{"class":57,"line":127},[55,5283,318],{"emptyLinePlaceholder":317},[55,5285,5286,5289,5292,5294,5297,5299,5302,5305,5308,5310],{"class":57,"line":135},[55,5287,5288],{"class":61},"redis-cli",[55,5290,5291],{"class":65}," -u",[55,5293,5264],{"class":69},[55,5295,5296],{"class":159},"$USAGE_REDIS_URL",[55,5298,4234],{"class":69},[55,5300,5301],{"class":69}," GET",[55,5303,5304],{"class":69}," \"usage:cus_test123:$(",[55,5306,5307],{"class":61},"date",[55,5309,5291],{"class":65},[55,5311,5312],{"class":69}," +%Y-%m)\"\n",[14,5314,5315,5316,5318,5319,5322,5323,5326,5327,5329],{},"You should see ",[52,5317,898],{},", a single row with ",[52,5320,5321],{},"route = \u002Fv1\u002Fextract"," and ",[52,5324,5325],{},"billable_units = 3",", and a Redis value that increased by exactly 3. If the row is absent after three seconds, the flusher task is not running — check that ",[52,5328,2144],{}," is attached to the app object you actually serve.",[45,5331,5333],{"className":282,"code":5332,"language":62,"meta":50,"style":50},"# tests\u002Ftest_usage_pipeline.py\nimport asyncio\n\nimport pytest\nfrom httpx import ASGITransport, AsyncClient\n\nfrom app import app\nfrom usage.middleware import usage_queue\n\n\n@pytest.mark.anyio\nasync def test_request_enqueues_billable_units():\n    transport = ASGITransport(app=app)\n    async with AsyncClient(transport=transport, base_url=\"http:\u002F\u002Ftest\") as client:\n        response = await client.post(\n            \"\u002Fv1\u002Fextract\", json={\"pages\": 3}, headers={\"Authorization\": \"Bearer test\"}\n        )\n    assert response.status_code == 200\n    event = await asyncio.wait_for(usage_queue.get(), timeout=1.0)\n    assert event[\"route\"] == \"\u002Fv1\u002Fextract\"\n    assert event[\"billable_units\"] == 3\n",[52,5334,5335,5340,5346,5350,5357,5369,5373,5385,5395,5399,5403,5408,5420,5438,5470,5481,5523,5527,5540,5561,5577],{"__ignoreMap":50},[55,5336,5337],{"class":57,"line":58},[55,5338,5339],{"class":290},"# tests\u002Ftest_usage_pipeline.py\n",[55,5341,5342,5344],{"class":57,"line":79},[55,5343,296],{"class":155},[55,5345,989],{"class":159},[55,5347,5348],{"class":57,"line":87},[55,5349,318],{"emptyLinePlaceholder":317},[55,5351,5352,5354],{"class":57,"line":95},[55,5353,296],{"class":155},[55,5355,5356],{"class":159}," pytest\n",[55,5358,5359,5361,5364,5366],{"class":57,"line":103},[55,5360,304],{"class":155},[55,5362,5363],{"class":159}," httpx ",[55,5365,296],{"class":155},[55,5367,5368],{"class":159}," ASGITransport, AsyncClient\n",[55,5370,5371],{"class":57,"line":111},[55,5372,318],{"emptyLinePlaceholder":317},[55,5374,5375,5377,5380,5382],{"class":57,"line":119},[55,5376,304],{"class":155},[55,5378,5379],{"class":159}," app ",[55,5381,296],{"class":155},[55,5383,5384],{"class":159}," app\n",[55,5386,5387,5389,5391,5393],{"class":57,"line":127},[55,5388,304],{"class":155},[55,5390,1551],{"class":159},[55,5392,296],{"class":155},[55,5394,1556],{"class":159},[55,5396,5397],{"class":57,"line":135},[55,5398,318],{"emptyLinePlaceholder":317},[55,5400,5401],{"class":57,"line":370},[55,5402,318],{"emptyLinePlaceholder":317},[55,5404,5405],{"class":57,"line":376},[55,5406,5407],{"class":61},"@pytest.mark.anyio\n",[55,5409,5410,5412,5414,5417],{"class":57,"line":381},[55,5411,1674],{"class":155},[55,5413,1144],{"class":155},[55,5415,5416],{"class":61}," test_request_enqueues_billable_units",[55,5418,5419],{"class":159},"():\n",[55,5421,5422,5425,5427,5430,5433,5435],{"class":57,"line":386},[55,5423,5424],{"class":159},"    transport ",[55,5426,163],{"class":155},[55,5428,5429],{"class":159}," ASGITransport(",[55,5431,5432],{"class":514},"app",[55,5434,163],{"class":155},[55,5436,5437],{"class":159},"app)\n",[55,5439,5440,5442,5444,5447,5450,5452,5455,5458,5460,5463,5465,5467],{"class":57,"line":401},[55,5441,1141],{"class":155},[55,5443,1876],{"class":155},[55,5445,5446],{"class":159}," AsyncClient(",[55,5448,5449],{"class":514},"transport",[55,5451,163],{"class":155},[55,5453,5454],{"class":159},"transport, ",[55,5456,5457],{"class":514},"base_url",[55,5459,163],{"class":155},[55,5461,5462],{"class":69},"\"http:\u002F\u002Ftest\"",[55,5464,3004],{"class":159},[55,5466,1882],{"class":155},[55,5468,5469],{"class":159}," client:\n",[55,5471,5472,5474,5476,5478],{"class":57,"line":412},[55,5473,1165],{"class":159},[55,5475,163],{"class":155},[55,5477,1170],{"class":155},[55,5479,5480],{"class":159}," client.post(\n",[55,5482,5483,5486,5488,5491,5493,5495,5498,5500,5502,5505,5508,5510,5512,5515,5517,5520],{"class":57,"line":423},[55,5484,5485],{"class":69},"            \"\u002Fv1\u002Fextract\"",[55,5487,432],{"class":159},[55,5489,5490],{"class":514},"json",[55,5492,163],{"class":155},[55,5494,2464],{"class":159},[55,5496,5497],{"class":69},"\"pages\"",[55,5499,474],{"class":159},[55,5501,4321],{"class":65},[55,5503,5504],{"class":159},"}, ",[55,5506,5507],{"class":514},"headers",[55,5509,163],{"class":155},[55,5511,2464],{"class":159},[55,5513,5514],{"class":69},"\"Authorization\"",[55,5516,474],{"class":159},[55,5518,5519],{"class":69},"\"Bearer test\"",[55,5521,5522],{"class":159},"}\n",[55,5524,5525],{"class":57,"line":446},[55,5526,3505],{"class":159},[55,5528,5529,5532,5535,5537],{"class":57,"line":465},[55,5530,5531],{"class":155},"    assert",[55,5533,5534],{"class":159}," response.status_code ",[55,5536,2267],{"class":155},[55,5538,5539],{"class":65}," 200\n",[55,5541,5542,5545,5547,5549,5552,5555,5557,5559],{"class":57,"line":483},[55,5543,5544],{"class":159},"    event ",[55,5546,163],{"class":155},[55,5548,1170],{"class":155},[55,5550,5551],{"class":159}," asyncio.wait_for(usage_queue.get(), ",[55,5553,5554],{"class":514},"timeout",[55,5556,163],{"class":155},[55,5558,4994],{"class":65},[55,5560,532],{"class":159},[55,5562,5563,5565,5568,5570,5572,5574],{"class":57,"line":489},[55,5564,5531],{"class":155},[55,5566,5567],{"class":159}," event[",[55,5569,456],{"class":69},[55,5571,506],{"class":159},[55,5573,2267],{"class":155},[55,5575,5576],{"class":69}," \"\u002Fv1\u002Fextract\"\n",[55,5578,5579,5581,5583,5585,5587,5589],{"class":57,"line":494},[55,5580,5531],{"class":155},[55,5582,5567],{"class":159},[55,5584,1364],{"class":69},[55,5586,506],{"class":159},[55,5588,2267],{"class":155},[55,5590,5256],{"class":65},[14,5592,5593,5594,5596],{},"Add one more test that asserts the quota counter returns ",[52,5595,1659],{}," on the call that crosses the limit. Enforcement bugs are silent in staging and expensive in production, so pin the boundary with an explicit assertion.",[27,5598,5600],{"id":5599},"cost-and-performance-at-scale","Cost and performance at scale",[14,5602,5603,5604,5606,5607,5610,5611,5615],{},"The measurable overhead of this design is small and predictable. The middleware adds roughly 0.3 ms per request: one Redis round trip on a local network plus a dictionary construction and a non-blocking queue put. At 10 million requests a month that is about 50 minutes of aggregate CPU, which on a $25 Render instance is well under a dollar. Storage dominates instead. A ",[52,5605,932],{}," row costs roughly 90 bytes with indexes, so 10 million requests produce about 900 MB per month of raw data — real money on managed Postgres, which is exactly why the 14-day raw retention and the hourly rollup exist. After rollup, the same 10 million requests compress to a few hundred thousand hourly rows, under 40 MB, and the daily table is noise. Redis holds one small integer key per customer per month, so even 5,000 customers fit in a few megabytes on the cheapest instance available. The failure mode that actually costs money is not compute, it is a rollup job left un-indexed: without ",[52,5608,5609],{},"ix_usage_customer_time",", the hourly aggregation degrades to a sequential scan over the current partition and starts competing with production traffic for I\u002FO. Add the index, keep the trailing window to three hours, and the job finishes in under two seconds on a hundred million rows. Once the numbers are landing reliably, expose them to customers — a self-serve view of consumption cuts billing support tickets sharply, and ",[18,5612,5614],{"href":5613},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Fbuilding-a-customer-usage-dashboard\u002F","building a customer usage dashboard"," walks through the queries and caching that make it fast.",[27,5617,5619],{"id":5618},"faq","FAQ",[14,5621,5622,5625],{},[5113,5623,5624],{},"How much does usage tracking cost to run at 10 million requests a month?","\nUnder five dollars if you follow the retention plan above. Compute overhead is roughly 50 minutes of aggregate CPU across the month, storage peaks at about 900 MB of raw events before the 14-day partition drop, and Redis needs only a few megabytes. Skip the rollups and keep raw events forever and that same volume grows past 10 GB a year, at which point managed Postgres storage becomes the largest line on the bill.",[14,5627,5628,5631],{},[5113,5629,5630],{},"Should I bill from Redis counters or from the Postgres event log?","\nAlways from Postgres. Redis is a real-time enforcement cache that can be evicted, failed over, or flushed by accident, and none of those events should change what a customer owes. The event log is append-only and replayable, so if you change your rate card in October you can recompute September for a refund. Use Redis to answer \"block this request now\" and Postgres to answer \"what is the invoice\".",[14,5633,5634,5637],{},[5113,5635,5636],{},"Does per-request tracking hurt my p99 latency enough to lose customers?","\nNo, if you keep the database off the request path. The measured cost is one Redis round trip plus a queue put — around 0.3 ms, which disappears under the network variance your customers already experience. The design that does hurt is a synchronous INSERT per request; that adds 3 to 15 ms and consumes pool connections your handlers need, which is how instrumentation ends up blamed for an outage it merely revealed.",[14,5639,5640,5643,5644,5322,5647,5649,5650,5654],{},[5113,5641,5642],{},"What happens to usage records when a customer rotates their API key mid-month?","\nNothing, as long as you store both ",[52,5645,5646],{},"api_key_id",[52,5648,2467],{}," on every event and key the quota counter on the customer rather than the key. Billing aggregates by customer, so a rotation is invisible on the invoice while per-key breakdowns still show which credential drove the traffic. See ",[18,5651,5653],{"href":5652},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002Frotating-api-keys-without-downtime\u002F","rotating API keys without downtime"," for the overlap window that makes rotation safe.",[14,5656,5657,5660,5661,5664],{},[5113,5658,5659],{},"How risky is migrating an existing API onto this pipeline?","\nLow, because you can run it in shadow mode. Deploy the middleware and flusher with ",[52,5662,5663],{},"USAGE_QUOTA_ENFORCED=false",", collect events for a full billing period, and compare your computed totals against whatever you invoice today. Only when the two agree within a fraction of a percent do you switch enforcement on and point Stripe at the meter. The one irreversible decision is the billable unit, so settle that before you collect a month of data you cannot reinterpret.",[27,5666,5668],{"id":5667},"related","Related",[5670,5671,5672,5679,5685,5691,5697],"ul",{},[5673,5674,5675,5678],"li",{},[18,5676,5677],{"href":777},"Logging API Usage Events to Postgres"," — the partitioning DDL, index tuning, and batch-insert benchmarks behind Step 3.",[5673,5680,5681,5684],{},[18,5682,5683],{"href":5613},"Building a Customer Usage Dashboard"," — turn the rollup tables into a self-serve view that cuts billing support tickets.",[5673,5686,5687,5690],{},[18,5688,5689],{"href":4817},"Stripe Metered Billing Configuration"," — meter setup, aggregation formulas, and subscription wiring for Step 7.",[5673,5692,5693,5696],{},[18,5694,5695],{"href":4298},"Calculating Cost per API Request"," — build the cost side of the margin table so the numbers mean something.",[5673,5698,5699,5703],{},[18,5700,5702],{"href":5701},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","Caching Python API Responses with Redis"," — the fastest way to cut the cost per call on the endpoints your margin report flags.",[5705,5706,5707],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":50,"searchDepth":79,"depth":79,"links":5709},[5710,5711,5712,5713,5714,5715,5716,5717,5718,5719,5720,5721,5722,5723],{"id":29,"depth":79,"text":30},{"id":256,"depth":79,"text":257},{"id":946,"depth":79,"text":947},{"id":1456,"depth":79,"text":1457},{"id":2164,"depth":79,"text":2165},{"id":2778,"depth":79,"text":2779},{"id":3647,"depth":79,"text":3648},{"id":4405,"depth":79,"text":4406},{"id":4926,"depth":79,"text":4927},{"id":5108,"depth":79,"text":5109},{"id":5175,"depth":79,"text":5176},{"id":5599,"depth":79,"text":5600},{"id":5618,"depth":79,"text":5619},{"id":5667,"depth":79,"text":5668},"Instrument a commercial Python API: per-key usage events, Redis quota counters, rollup windows, revenue-per-endpoint margin queries, and Stripe metered billing.","md",{"pageTitle":5727,"type":5728,"datePublished":5729,"dateModified":5729},"Tracking API Usage and Analytics for Python API Builders","guide","2026-07-23","\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics",{"title":5,"description":5724},"building-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Findex","0P_7r5gflr_BZZwOW4VRlFZErzcRW6QbLnfM63_8HDw",{"@context":5735,"@type":5736,"mainEntity":5737},"https:\u002F\u002Fschema.org","FAQPage",[5738,5743,5746,5749,5752],{"@type":5739,"name":5624,"acceptedAnswer":5740},"Question",{"@type":5741,"text":5742},"Answer","Under five dollars if you follow the retention plan above. Compute overhead is roughly 50 minutes of aggregate CPU across the month, storage peaks at about 900 MB of raw events before the 14-day partition drop, and Redis needs only a few megabytes. Skip the rollups and keep raw events forever and that same volume grows past 10 GB a year, at which point managed Postgres storage becomes the largest line on the bill.",{"@type":5739,"name":5630,"acceptedAnswer":5744},{"@type":5741,"text":5745},"Always from Postgres. Redis is a real-time enforcement cache that can be evicted, failed over, or flushed by accident, and none of those events should change what a customer owes. The event log is append-only and replayable, so if you change your rate card in October you can recompute September for a refund. Use Redis to answer \"block this request now\" and Postgres to answer \"what is the invoice\".",{"@type":5739,"name":5636,"acceptedAnswer":5747},{"@type":5741,"text":5748},"No, if you keep the database off the request path. The measured cost is one Redis round trip plus a queue put — around 0.3 ms, which disappears under the network variance your customers already experience. The design that does hurt is a synchronous INSERT per request; that adds 3 to 15 ms and consumes pool connections your handlers need, which is how instrumentation ends up blamed for an outage it merely revealed.",{"@type":5739,"name":5642,"acceptedAnswer":5750},{"@type":5741,"text":5751},"Nothing, as long as you store both api_key_id and customer_id on every event and key the quota counter on the customer rather than the key. Billing aggregates by customer, so a rotation is invisible on the invoice while per-key breakdowns still show which credential drove the traffic. See rotating API keys without downtime for the overlap window that makes rotation safe.",{"@type":5739,"name":5659,"acceptedAnswer":5753},{"@type":5741,"text":5754},"Low, because you can run it in shadow mode. Deploy the middleware and flusher with USAGE_QUOTA_ENFORCED=false, collect events for a full billing period, and compare your computed totals against whatever you invoice today. Only when the two agree within a fraction of a percent do you switch enforcement on and point Stripe at the meter. The one irreversible decision is the billable unit, so settle that before you collect a month of data you cannot reinterpret.",1784887027767]