[{"data":1,"prerenderedAt":3046},["ShallowReactive",2],{"page-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Fbuilding-a-customer-usage-dashboard\u002F":3,"faq-schema-\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Fbuilding-a-customer-usage-dashboard\u002F":3025},{"id":4,"title":5,"body":6,"description":3015,"extension":3016,"meta":3017,"navigation":527,"path":3021,"seo":3022,"stem":3023,"__hash__":3024},"content\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Fbuilding-a-customer-usage-dashboard\u002Findex.md","Building a Customer Usage Dashboard",{"type":7,"value":8,"toc":3003},"minimark",[9,13,23,31,36,47,119,122,279,282,377,381,393,409,489,1416,1434,1445,1449,1452,1455,2243,2258,2339,2343,2351,2357,2725,2740,2744,2747,2831,2839,2843,2846,2849,2857,2861,2864,2905,2909,2917,2921,2928,2937,2943,2952,2961,2965,2999],[10,11,5],"h1",{"id":12},"building-a-customer-usage-dashboard",[14,15,16,17,22],"p",{},"The decision this page resolves is narrow and expensive to get wrong: does your customer-facing usage view query raw events at read time, or does it read pre-aggregated rollups behind a short cache? Part of the ",[18,19,21],"a",{"href":20},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002F","Tracking API Usage and Analytics"," guide, this article picks the second option and shows the endpoint, the quota panel, the cache layer, and the exposure rules that keep a dashboard both fast and safe.",[14,24,25,26,30],{},"The commercial argument comes first. A dashboard is the cheapest support-ticket deflection you will ever ship. Every \"why was my invoice $340 this month?\" email costs you fifteen minutes of founder time and a dent in trust. A self-serve view that answers the question at 2am costs one endpoint, one cache key, and about eighty lines of Python. The trap is that the naive version — a ",[27,28,29],"code",{},"SELECT"," over your raw event table every time someone opens the page — turns your smallest revenue feature into the heaviest query on your database, on exactly the day a big customer audits their spend.",[32,33,35],"h2",{"id":34},"raw-scan-versus-rollup-the-read-path-that-decides-everything","Raw scan versus rollup: the read path that decides everything",[14,37,38,39,42,43,46],{},"Assume the event pipeline from the parent guide: one immutable row per request in ",[27,40,41],{},"usage_event",", and an ",[27,44,45],{},"usage_hourly"," table rebuilt every ten minutes. The dashboard has three candidate sources, and they differ by two orders of magnitude in cost.",[48,49,50,66],"table",{},[51,52,53],"thead",{},[54,55,56,60,63],"tr",{},[57,58,59],"th",{},"Read source",[57,61,62],{},"30-day query",[57,64,65],{},"Freshness",[67,68,69,84,97,108],"tbody",{},[54,70,71,78,81],{},[72,73,74,75,77],"td",{},"Raw ",[27,76,41],{}," scan",[72,79,80],{},"~1,900 ms",[72,82,83],{},"Instant",[54,85,86,91,94],{},[72,87,88,90],{},[27,89,45],{}," rollup",[72,92,93],{},"~42 ms",[72,95,96],{},"10 min",[54,98,99,102,105],{},[72,100,101],{},"Daily rollup",[72,103,104],{},"~9 ms",[72,106,107],{},"1 hour",[54,109,110,113,116],{},[72,111,112],{},"Redis-cached JSON",[72,114,115],{},"~3 ms",[72,117,118],{},"60 s stale",[14,120,121],{},"Those numbers come from a customer doing 3.6 million calls a month on a 2 vCPU managed Postgres instance. The raw scan touches roughly 3.6 million rows across two partitions; the hourly rollup touches about 4,300; the cached response touches nothing. Read that as a cost table, not a latency table: the raw scan burns I\u002FO your paying request path needs, so one enterprise customer's refresh loop measurably slows the API they pay for.",[123,124,132,133,132,137,132,141,132,148,132,167,132,176,132,184,132,191,132,196,132,200,132,204,132,209,132,213,132,217,132,223,132,226,132,230,132,235,132,241,132,243,132,246,132,250,132,254,132,258,132,262,132,265,132,267,132,270,132,274],"svg",{"viewBox":125,"role":126,"ariaLabelledBy":127,"xmlns":130,"style":131},"0 0 800 340","img",[128,129],"cud-flow-t","cud-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[134,135,136],"title",{"id":128},"Dashboard read path with cache hit and miss branches",[138,139,140],"desc",{"id":129},"A dashboard client authenticates, hits a sixty-second Redis cache, and on a miss reads the hourly rollup table and the live quota counter before filling the cache and returning JSON.",[142,143],"rect",{"x":144,"y":144,"width":145,"height":146,"fill":147},"0","800","340","var(--c-surface)",[149,150,151,152,132],"defs",{},"\n    ",[153,154,161,162,151],"marker",{"id":155,"viewBox":156,"refX":157,"refY":158,"markerWidth":159,"markerHeight":159,"orient":160},"cud-flow-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[163,164],"path",{"d":165,"fill":166},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[142,168],{"x":169,"y":170,"width":171,"height":172,"rx":173,"fill":147,"stroke":174,"style":175},"24","44","150","52","10","var(--c-blue)","stroke-width:2;",[177,178,183],"text",{"x":179,"y":180,"fill":181,"style":182},"99","76","var(--c-text)","text-anchor:middle;font-size:12;font-family:var(--font-sans);","Dashboard client",[185,186],"line",{"x1":187,"y1":188,"x2":189,"y2":188,"stroke":166,"style":190},"174","70","212","stroke-width:2;marker-end:url(#cud-flow-arrow);",[142,192],{"x":193,"y":170,"width":194,"height":172,"rx":173,"fill":147,"stroke":195,"style":175},"214","170","var(--c-teal)",[177,197,199],{"x":198,"y":180,"fill":181,"style":182},"299","Auth to customer_id",[185,201],{"x1":202,"y1":188,"x2":203,"y2":188,"stroke":166,"style":190},"384","422",[142,205],{"x":206,"y":170,"width":207,"height":172,"rx":173,"fill":208,"stroke":195,"style":175},"424","160","var(--c-surface-alt)",[177,210,212],{"x":211,"y":180,"fill":181,"style":182},"504","Redis cache 60s",[185,214],{"x1":215,"y1":188,"x2":216,"y2":188,"stroke":195,"style":190},"584","622",[177,218,222],{"x":219,"y":220,"fill":195,"style":221},"603","60","text-anchor:middle;font-size:11;font-family:var(--font-sans);","hit",[142,224],{"x":225,"y":170,"width":171,"height":172,"rx":173,"fill":147,"stroke":174,"style":175},"624",[177,227,229],{"x":228,"y":180,"fill":181,"style":182},"699","JSON + as_of",[185,231],{"x1":211,"y1":232,"x2":211,"y2":233,"stroke":234,"style":190},"96","148","var(--c-coral)",[177,236,240],{"x":237,"y":238,"fill":234,"style":239},"536","126","font-size:11;font-family:var(--font-sans);","miss",[142,242],{"x":206,"y":171,"width":207,"height":172,"rx":173,"fill":147,"stroke":174,"style":175},[177,244,245],{"x":211,"y":187,"fill":181,"style":221},"usage_hourly rollup",[177,247,249],{"x":211,"y":248,"fill":166,"style":221},"190","4.3k rows scanned",[185,251],{"x1":211,"y1":252,"x2":211,"y2":253,"stroke":166,"style":190},"202","254",[142,255],{"x":206,"y":256,"width":207,"height":172,"rx":173,"fill":147,"stroke":257,"style":175},"256","var(--c-yellow)",[177,259,261],{"x":211,"y":260,"fill":181,"style":221},"288","Live quota counter",[185,263],{"x1":215,"y1":264,"x2":216,"y2":264,"stroke":166,"style":190},"282",[142,266],{"x":225,"y":256,"width":171,"height":172,"rx":173,"fill":208,"stroke":195,"style":175},[177,268,269],{"x":228,"y":260,"fill":181,"style":221},"Fill cache, tag",[185,271],{"x1":228,"y1":256,"x2":228,"y2":272,"stroke":166,"style":273},"100","stroke-width:2;stroke-dasharray:4 3;marker-end:url(#cud-flow-arrow);",[177,275,278],{"x":276,"y":277,"fill":166,"style":239},"30","322","Raw usage_event is never on this path. It only feeds the rollup job and dispute replays.",[14,280,281],{},"Pick the rollup plus cache path and never look back. The only cost is freshness, and freshness is a product decision you can state in one line of UI copy. Customers accept \"usage updates every few minutes\" without blinking; they do not accept a spinner.",[123,283,132,288,132,291,132,294,132,298,132,303,132,308,132,314,132,318,132,321,132,325,132,329,132,331,132,335,132,339,132,343,132,347,132,351,132,357,132,361,132,365,132,369,132,373],{"viewBox":284,"role":126,"ariaLabelledBy":285,"xmlns":130,"style":131},"0 0 760 300",[286,287],"cud-lat-t","cud-lat-d",[134,289,290],{"id":286},"Dashboard query latency by read source on a log scale",[138,292,293],{"id":287},"Log-scale bars comparing a raw event scan at 1900 milliseconds, hourly rollup at 42, daily rollup at 9, and a cached response at 3 milliseconds.",[142,295],{"x":144,"y":144,"width":296,"height":297,"fill":147},"760","300",[177,299,302],{"x":276,"y":300,"fill":181,"style":301},"32","font-size:13;font-family:var(--font-sans);","30-day dashboard query, 3.6M calls\u002Fmonth customer (log scale)",[177,304,307],{"x":248,"y":305,"fill":181,"style":306},"78","text-anchor:end;font-size:12;font-family:var(--font-sans);","Raw event scan",[142,309],{"x":310,"y":220,"width":311,"height":312,"rx":313,"fill":208,"stroke":234,"style":175},"200","393","26","4",[177,315,317],{"x":219,"y":305,"fill":234,"style":316},"font-size:12;font-family:var(--font-sans);","1,900 ms",[177,319,320],{"x":248,"y":238,"fill":181,"style":306},"Hourly rollup",[142,322],{"x":310,"y":323,"width":324,"height":312,"rx":313,"fill":208,"stroke":174,"style":175},"108","195",[177,326,328],{"x":327,"y":238,"fill":181,"style":316},"405","42 ms",[177,330,101],{"x":248,"y":187,"fill":181,"style":306},[142,332],{"x":310,"y":333,"width":334,"height":312,"rx":313,"fill":208,"stroke":174,"style":175},"156","114",[177,336,338],{"x":337,"y":187,"fill":181,"style":316},"324","9 ms",[177,340,342],{"x":248,"y":341,"fill":181,"style":306},"222","Cached JSON",[142,344],{"x":310,"y":345,"width":346,"height":312,"rx":313,"fill":208,"stroke":195,"style":175},"204","57",[177,348,350],{"x":349,"y":341,"fill":195,"style":316},"267","3 ms",[185,352],{"x1":310,"y1":353,"x2":354,"y2":353,"stroke":355,"style":356},"246","620","var(--c-border)","stroke-width:1.5;",[177,358,360],{"x":310,"y":359,"fill":166,"style":221},"266","1 ms",[177,362,364],{"x":363,"y":359,"fill":166,"style":221},"320","10 ms",[177,366,368],{"x":367,"y":359,"fill":166,"style":221},"440","100 ms",[177,370,372],{"x":371,"y":359,"fill":166,"style":221},"560","1 s",[177,374,376],{"x":276,"y":375,"fill":166,"style":239},"290","Same answer, 630x cheaper. The raw scan competes with paid traffic for I\u002FO.",[32,378,380],{"id":379},"the-read-only-usage-endpoint","The read-only usage endpoint",[14,382,383,384,387,388,392],{},"Build the API before the UI. Your dashboard should be a client of the same public endpoint your customers can call themselves, because a documented ",[27,385,386],{},"GET \u002Fv1\u002Fusage"," is a feature that shows up in your ",[18,389,391],{"href":390},"\u002Fgetting-started-with-python-apis-for-builders\u002Fdocumenting-apis-with-openapi\u002F","OpenAPI documentation"," and lets a customer wire your numbers into their own finance tooling. Two implementations for the price of one.",[14,394,395,396,399,400,404,405,408],{},"Three rules govern this endpoint. It is read-only, so it never accepts a body and never mutates a counter. It is scoped by the authenticated identity, never by a ",[27,397,398],{},"customer_id"," query parameter — the ",[18,401,403],{"href":402},"\u002Fgetting-started-with-python-apis-for-builders\u002Fhandling-api-authentication-in-python\u002F","API key authentication layer"," resolves the caller, and a caller can only ever see themselves. And it caps the range, because an unbounded ",[27,406,407],{},"days"," parameter is a denial-of-service vector disguised as a feature.",[410,411,416],"pre",{"className":412,"code":413,"language":414,"meta":415,"style":415},"language-bash shiki shiki-themes github-light github-dark","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_DASHBOARD_CACHE_TTL=\"60\"\nexport USAGE_DASHBOARD_MAX_DAYS=\"62\"\nexport USAGE_QUOTA_ALERT_RATIO=\"0.8\"\n","bash","",[27,417,418,437,450,463,476],{"__ignoreMap":415},[419,420,422,426,430,433],"span",{"class":185,"line":421},1,[419,423,425],{"class":424},"szBVR","export",[419,427,429],{"class":428},"sVt8B"," USAGE_DATABASE_URL",[419,431,432],{"class":424},"=",[419,434,436],{"class":435},"sZZnC","\"postgresql+asyncpg:\u002F\u002Fapi:secret@db.internal:5432\u002Fapiprod\"\n",[419,438,440,442,445,447],{"class":185,"line":439},2,[419,441,425],{"class":424},[419,443,444],{"class":428}," USAGE_REDIS_URL",[419,446,432],{"class":424},[419,448,449],{"class":435},"\"redis:\u002F\u002Fcache.internal:6379\u002F2\"\n",[419,451,453,455,458,460],{"class":185,"line":452},3,[419,454,425],{"class":424},[419,456,457],{"class":428}," USAGE_DASHBOARD_CACHE_TTL",[419,459,432],{"class":424},[419,461,462],{"class":435},"\"60\"\n",[419,464,466,468,471,473],{"class":185,"line":465},4,[419,467,425],{"class":424},[419,469,470],{"class":428}," USAGE_DASHBOARD_MAX_DAYS",[419,472,432],{"class":424},[419,474,475],{"class":435},"\"62\"\n",[419,477,479,481,484,486],{"class":185,"line":478},5,[419,480,425],{"class":424},[419,482,483],{"class":428}," USAGE_QUOTA_ALERT_RATIO",[419,485,432],{"class":424},[419,487,488],{"class":435},"\"0.8\"\n",[410,490,494],{"className":491,"code":492,"language":493,"meta":415,"style":415},"language-python shiki shiki-themes github-light github-dark","# usage\u002Fdashboard.py\nimport os\nfrom datetime import datetime, timedelta, timezone\n\nfrom fastapi import APIRouter, Depends, HTTPException, Query, Response\nfrom pydantic import BaseModel\nfrom sqlalchemy import text\n\nfrom usage.auth import current_customer  # resolves the presented key to a customer id\nfrom usage.flusher import Session\n\nrouter = APIRouter(prefix=\"\u002Fv1\u002Fusage\", tags=[\"usage\"])\n\nMAX_RANGE_DAYS = int(os.getenv(\"USAGE_DASHBOARD_MAX_DAYS\", \"62\"))\nCACHE_TTL = int(os.getenv(\"USAGE_DASHBOARD_CACHE_TTL\", \"60\"))\n\nSERIES_SQL = \"\"\"\nSELECT {bucket_expr} AS bucket,\n       sum(calls)::bigint    AS calls,\n       sum(billable)::bigint AS billable,\n       sum(errors)::bigint   AS errors,\n       round(sum(duration_sum)::numeric \u002F greatest(sum(calls), 1), 1) AS avg_ms\nFROM usage_hourly\nWHERE customer_id = :customer_id\n  AND bucket >= :start AND bucket \u003C :end\nGROUP BY 1\nORDER BY 1\n\"\"\"\n\n\nclass UsagePoint(BaseModel):\n    bucket: datetime\n    calls: int\n    billable: int\n    errors: int\n    avg_ms: float\n\n\nclass UsageSeries(BaseModel):\n    granularity: str\n    as_of: datetime\n    points: list[UsagePoint]\n\n\n@router.get(\"\", response_model=UsageSeries)\nasync def usage_series(\n    response: Response,\n    days: int = Query(30, ge=1),\n    granularity: str = Query(\"day\"),\n    customer_id: str = Depends(current_customer),\n) -> UsageSeries:\n    if days > MAX_RANGE_DAYS:\n        raise HTTPException(status_code=422, detail=f\"days must be \u003C= {MAX_RANGE_DAYS}\")\n    match granularity:\n        case \"hour\":\n            bucket_expr = \"bucket\"\n        case \"day\":\n            bucket_expr = \"date_trunc('day', bucket)\"\n        case _:\n            raise HTTPException(status_code=422, detail=\"granularity is 'hour' or 'day'\")\n\n    end = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)\n    end += timedelta(hours=1)\n    start = end - timedelta(days=days)\n    async with Session() as session:\n        rows = await session.execute(\n            text(SERIES_SQL.format(bucket_expr=bucket_expr)),\n            {\"customer_id\": customer_id, \"start\": start, \"end\": end},\n        )\n        points = [UsagePoint(**row._mapping) for row in rows]\n\n    response.headers[\"Cache-Control\"] = f\"private, max-age={CACHE_TTL}\"\n    return UsageSeries(granularity=granularity, as_of=end, points=points)\n","python",[27,495,496,502,510,523,529,541,554,567,572,588,601,606,643,648,675,697,702,713,725,731,737,743,749,755,761,767,773,779,785,790,795,814,820,829,837,845,854,859,864,878,887,893,899,904,909,930,945,951,980,997,1010,1016,1034,1072,1081,1092,1103,1113,1123,1131,1156,1161,1199,1219,1242,1260,1274,1293,1317,1323,1352,1357,1383],{"__ignoreMap":415},[419,497,498],{"class":185,"line":421},[419,499,501],{"class":500},"sJ8bj","# usage\u002Fdashboard.py\n",[419,503,504,507],{"class":185,"line":439},[419,505,506],{"class":424},"import",[419,508,509],{"class":428}," os\n",[419,511,512,515,518,520],{"class":185,"line":452},[419,513,514],{"class":424},"from",[419,516,517],{"class":428}," datetime ",[419,519,506],{"class":424},[419,521,522],{"class":428}," datetime, timedelta, timezone\n",[419,524,525],{"class":185,"line":465},[419,526,528],{"emptyLinePlaceholder":527},true,"\n",[419,530,531,533,536,538],{"class":185,"line":478},[419,532,514],{"class":424},[419,534,535],{"class":428}," fastapi ",[419,537,506],{"class":424},[419,539,540],{"class":428}," APIRouter, Depends, HTTPException, Query, Response\n",[419,542,544,546,549,551],{"class":185,"line":543},6,[419,545,514],{"class":424},[419,547,548],{"class":428}," pydantic ",[419,550,506],{"class":424},[419,552,553],{"class":428}," BaseModel\n",[419,555,557,559,562,564],{"class":185,"line":556},7,[419,558,514],{"class":424},[419,560,561],{"class":428}," sqlalchemy ",[419,563,506],{"class":424},[419,565,566],{"class":428}," text\n",[419,568,570],{"class":185,"line":569},8,[419,571,528],{"emptyLinePlaceholder":527},[419,573,575,577,580,582,585],{"class":185,"line":574},9,[419,576,514],{"class":424},[419,578,579],{"class":428}," usage.auth ",[419,581,506],{"class":424},[419,583,584],{"class":428}," current_customer  ",[419,586,587],{"class":500},"# resolves the presented key to a customer id\n",[419,589,591,593,596,598],{"class":185,"line":590},10,[419,592,514],{"class":424},[419,594,595],{"class":428}," usage.flusher ",[419,597,506],{"class":424},[419,599,600],{"class":428}," Session\n",[419,602,604],{"class":185,"line":603},11,[419,605,528],{"emptyLinePlaceholder":527},[419,607,609,612,614,617,621,623,626,629,632,634,637,640],{"class":185,"line":608},12,[419,610,611],{"class":428},"router ",[419,613,432],{"class":424},[419,615,616],{"class":428}," APIRouter(",[419,618,620],{"class":619},"s4XuR","prefix",[419,622,432],{"class":424},[419,624,625],{"class":435},"\"\u002Fv1\u002Fusage\"",[419,627,628],{"class":428},", ",[419,630,631],{"class":619},"tags",[419,633,432],{"class":424},[419,635,636],{"class":428},"[",[419,638,639],{"class":435},"\"usage\"",[419,641,642],{"class":428},"])\n",[419,644,646],{"class":185,"line":645},13,[419,647,528],{"emptyLinePlaceholder":527},[419,649,651,655,658,661,664,667,669,672],{"class":185,"line":650},14,[419,652,654],{"class":653},"sj4cs","MAX_RANGE_DAYS",[419,656,657],{"class":424}," =",[419,659,660],{"class":653}," int",[419,662,663],{"class":428},"(os.getenv(",[419,665,666],{"class":435},"\"USAGE_DASHBOARD_MAX_DAYS\"",[419,668,628],{"class":428},[419,670,671],{"class":435},"\"62\"",[419,673,674],{"class":428},"))\n",[419,676,678,681,683,685,687,690,692,695],{"class":185,"line":677},15,[419,679,680],{"class":653},"CACHE_TTL",[419,682,657],{"class":424},[419,684,660],{"class":653},[419,686,663],{"class":428},[419,688,689],{"class":435},"\"USAGE_DASHBOARD_CACHE_TTL\"",[419,691,628],{"class":428},[419,693,694],{"class":435},"\"60\"",[419,696,674],{"class":428},[419,698,700],{"class":185,"line":699},16,[419,701,528],{"emptyLinePlaceholder":527},[419,703,705,708,710],{"class":185,"line":704},17,[419,706,707],{"class":653},"SERIES_SQL",[419,709,657],{"class":424},[419,711,712],{"class":435}," \"\"\"\n",[419,714,716,719,722],{"class":185,"line":715},18,[419,717,718],{"class":435},"SELECT ",[419,720,721],{"class":653},"{bucket_expr}",[419,723,724],{"class":435}," AS bucket,\n",[419,726,728],{"class":185,"line":727},19,[419,729,730],{"class":435},"       sum(calls)::bigint    AS calls,\n",[419,732,734],{"class":185,"line":733},20,[419,735,736],{"class":435},"       sum(billable)::bigint AS billable,\n",[419,738,740],{"class":185,"line":739},21,[419,741,742],{"class":435},"       sum(errors)::bigint   AS errors,\n",[419,744,746],{"class":185,"line":745},22,[419,747,748],{"class":435},"       round(sum(duration_sum)::numeric \u002F greatest(sum(calls), 1), 1) AS avg_ms\n",[419,750,752],{"class":185,"line":751},23,[419,753,754],{"class":435},"FROM usage_hourly\n",[419,756,758],{"class":185,"line":757},24,[419,759,760],{"class":435},"WHERE customer_id = :customer_id\n",[419,762,764],{"class":185,"line":763},25,[419,765,766],{"class":435},"  AND bucket >= :start AND bucket \u003C :end\n",[419,768,770],{"class":185,"line":769},26,[419,771,772],{"class":435},"GROUP BY 1\n",[419,774,776],{"class":185,"line":775},27,[419,777,778],{"class":435},"ORDER BY 1\n",[419,780,782],{"class":185,"line":781},28,[419,783,784],{"class":435},"\"\"\"\n",[419,786,788],{"class":185,"line":787},29,[419,789,528],{"emptyLinePlaceholder":527},[419,791,793],{"class":185,"line":792},30,[419,794,528],{"emptyLinePlaceholder":527},[419,796,798,801,805,808,811],{"class":185,"line":797},31,[419,799,800],{"class":424},"class",[419,802,804],{"class":803},"sScJk"," UsagePoint",[419,806,807],{"class":428},"(",[419,809,810],{"class":803},"BaseModel",[419,812,813],{"class":428},"):\n",[419,815,817],{"class":185,"line":816},32,[419,818,819],{"class":428},"    bucket: datetime\n",[419,821,823,826],{"class":185,"line":822},33,[419,824,825],{"class":428},"    calls: ",[419,827,828],{"class":653},"int\n",[419,830,832,835],{"class":185,"line":831},34,[419,833,834],{"class":428},"    billable: ",[419,836,828],{"class":653},[419,838,840,843],{"class":185,"line":839},35,[419,841,842],{"class":428},"    errors: ",[419,844,828],{"class":653},[419,846,848,851],{"class":185,"line":847},36,[419,849,850],{"class":428},"    avg_ms: ",[419,852,853],{"class":653},"float\n",[419,855,857],{"class":185,"line":856},37,[419,858,528],{"emptyLinePlaceholder":527},[419,860,862],{"class":185,"line":861},38,[419,863,528],{"emptyLinePlaceholder":527},[419,865,867,869,872,874,876],{"class":185,"line":866},39,[419,868,800],{"class":424},[419,870,871],{"class":803}," UsageSeries",[419,873,807],{"class":428},[419,875,810],{"class":803},[419,877,813],{"class":428},[419,879,881,884],{"class":185,"line":880},40,[419,882,883],{"class":428},"    granularity: ",[419,885,886],{"class":653},"str\n",[419,888,890],{"class":185,"line":889},41,[419,891,892],{"class":428},"    as_of: datetime\n",[419,894,896],{"class":185,"line":895},42,[419,897,898],{"class":428},"    points: list[UsagePoint]\n",[419,900,902],{"class":185,"line":901},43,[419,903,528],{"emptyLinePlaceholder":527},[419,905,907],{"class":185,"line":906},44,[419,908,528],{"emptyLinePlaceholder":527},[419,910,912,915,917,920,922,925,927],{"class":185,"line":911},45,[419,913,914],{"class":803},"@router.get",[419,916,807],{"class":428},[419,918,919],{"class":435},"\"\"",[419,921,628],{"class":428},[419,923,924],{"class":619},"response_model",[419,926,432],{"class":424},[419,928,929],{"class":428},"UsageSeries)\n",[419,931,933,936,939,942],{"class":185,"line":932},46,[419,934,935],{"class":424},"async",[419,937,938],{"class":424}," def",[419,940,941],{"class":803}," usage_series",[419,943,944],{"class":428},"(\n",[419,946,948],{"class":185,"line":947},47,[419,949,950],{"class":428},"    response: Response,\n",[419,952,954,957,960,962,965,967,969,972,974,977],{"class":185,"line":953},48,[419,955,956],{"class":428},"    days: ",[419,958,959],{"class":653},"int",[419,961,657],{"class":424},[419,963,964],{"class":428}," Query(",[419,966,276],{"class":653},[419,968,628],{"class":428},[419,970,971],{"class":619},"ge",[419,973,432],{"class":424},[419,975,976],{"class":653},"1",[419,978,979],{"class":428},"),\n",[419,981,983,985,988,990,992,995],{"class":185,"line":982},49,[419,984,883],{"class":428},[419,986,987],{"class":653},"str",[419,989,657],{"class":424},[419,991,964],{"class":428},[419,993,994],{"class":435},"\"day\"",[419,996,979],{"class":428},[419,998,1000,1003,1005,1007],{"class":185,"line":999},50,[419,1001,1002],{"class":428},"    customer_id: ",[419,1004,987],{"class":653},[419,1006,657],{"class":424},[419,1008,1009],{"class":428}," Depends(current_customer),\n",[419,1011,1013],{"class":185,"line":1012},51,[419,1014,1015],{"class":428},") -> UsageSeries:\n",[419,1017,1019,1022,1025,1028,1031],{"class":185,"line":1018},52,[419,1020,1021],{"class":424},"    if",[419,1023,1024],{"class":428}," days ",[419,1026,1027],{"class":424},">",[419,1029,1030],{"class":653}," MAX_RANGE_DAYS",[419,1032,1033],{"class":428},":\n",[419,1035,1037,1040,1043,1046,1048,1050,1052,1055,1057,1060,1063,1066,1069],{"class":185,"line":1036},53,[419,1038,1039],{"class":424},"        raise",[419,1041,1042],{"class":428}," HTTPException(",[419,1044,1045],{"class":619},"status_code",[419,1047,432],{"class":424},[419,1049,203],{"class":653},[419,1051,628],{"class":428},[419,1053,1054],{"class":619},"detail",[419,1056,432],{"class":424},[419,1058,1059],{"class":424},"f",[419,1061,1062],{"class":435},"\"days must be \u003C= ",[419,1064,1065],{"class":653},"{MAX_RANGE_DAYS}",[419,1067,1068],{"class":435},"\"",[419,1070,1071],{"class":428},")\n",[419,1073,1075,1078],{"class":185,"line":1074},54,[419,1076,1077],{"class":424},"    match",[419,1079,1080],{"class":428}," granularity:\n",[419,1082,1084,1087,1090],{"class":185,"line":1083},55,[419,1085,1086],{"class":424},"        case",[419,1088,1089],{"class":435}," \"hour\"",[419,1091,1033],{"class":428},[419,1093,1095,1098,1100],{"class":185,"line":1094},56,[419,1096,1097],{"class":428},"            bucket_expr ",[419,1099,432],{"class":424},[419,1101,1102],{"class":435}," \"bucket\"\n",[419,1104,1106,1108,1111],{"class":185,"line":1105},57,[419,1107,1086],{"class":424},[419,1109,1110],{"class":435}," \"day\"",[419,1112,1033],{"class":428},[419,1114,1116,1118,1120],{"class":185,"line":1115},58,[419,1117,1097],{"class":428},[419,1119,432],{"class":424},[419,1121,1122],{"class":435}," \"date_trunc('day', bucket)\"\n",[419,1124,1126,1128],{"class":185,"line":1125},59,[419,1127,1086],{"class":424},[419,1129,1130],{"class":428}," _:\n",[419,1132,1134,1137,1139,1141,1143,1145,1147,1149,1151,1154],{"class":185,"line":1133},60,[419,1135,1136],{"class":424},"            raise",[419,1138,1042],{"class":428},[419,1140,1045],{"class":619},[419,1142,432],{"class":424},[419,1144,203],{"class":653},[419,1146,628],{"class":428},[419,1148,1054],{"class":619},[419,1150,432],{"class":424},[419,1152,1153],{"class":435},"\"granularity is 'hour' or 'day'\"",[419,1155,1071],{"class":428},[419,1157,1159],{"class":185,"line":1158},61,[419,1160,528],{"emptyLinePlaceholder":527},[419,1162,1164,1167,1169,1172,1175,1177,1179,1181,1184,1186,1188,1190,1193,1195,1197],{"class":185,"line":1163},62,[419,1165,1166],{"class":428},"    end ",[419,1168,432],{"class":424},[419,1170,1171],{"class":428}," datetime.now(timezone.utc).replace(",[419,1173,1174],{"class":619},"minute",[419,1176,432],{"class":424},[419,1178,144],{"class":653},[419,1180,628],{"class":428},[419,1182,1183],{"class":619},"second",[419,1185,432],{"class":424},[419,1187,144],{"class":653},[419,1189,628],{"class":428},[419,1191,1192],{"class":619},"microsecond",[419,1194,432],{"class":424},[419,1196,144],{"class":653},[419,1198,1071],{"class":428},[419,1200,1202,1204,1207,1210,1213,1215,1217],{"class":185,"line":1201},63,[419,1203,1166],{"class":428},[419,1205,1206],{"class":424},"+=",[419,1208,1209],{"class":428}," timedelta(",[419,1211,1212],{"class":619},"hours",[419,1214,432],{"class":424},[419,1216,976],{"class":653},[419,1218,1071],{"class":428},[419,1220,1222,1225,1227,1230,1233,1235,1237,1239],{"class":185,"line":1221},64,[419,1223,1224],{"class":428},"    start ",[419,1226,432],{"class":424},[419,1228,1229],{"class":428}," end ",[419,1231,1232],{"class":424},"-",[419,1234,1209],{"class":428},[419,1236,407],{"class":619},[419,1238,432],{"class":424},[419,1240,1241],{"class":428},"days)\n",[419,1243,1245,1248,1251,1254,1257],{"class":185,"line":1244},65,[419,1246,1247],{"class":424},"    async",[419,1249,1250],{"class":424}," with",[419,1252,1253],{"class":428}," Session() ",[419,1255,1256],{"class":424},"as",[419,1258,1259],{"class":428}," session:\n",[419,1261,1263,1266,1268,1271],{"class":185,"line":1262},66,[419,1264,1265],{"class":428},"        rows ",[419,1267,432],{"class":424},[419,1269,1270],{"class":424}," await",[419,1272,1273],{"class":428}," session.execute(\n",[419,1275,1277,1280,1282,1285,1288,1290],{"class":185,"line":1276},67,[419,1278,1279],{"class":428},"            text(",[419,1281,707],{"class":653},[419,1283,1284],{"class":428},".format(",[419,1286,1287],{"class":619},"bucket_expr",[419,1289,432],{"class":424},[419,1291,1292],{"class":428},"bucket_expr)),\n",[419,1294,1296,1299,1302,1305,1308,1311,1314],{"class":185,"line":1295},68,[419,1297,1298],{"class":428},"            {",[419,1300,1301],{"class":435},"\"customer_id\"",[419,1303,1304],{"class":428},": customer_id, ",[419,1306,1307],{"class":435},"\"start\"",[419,1309,1310],{"class":428},": start, ",[419,1312,1313],{"class":435},"\"end\"",[419,1315,1316],{"class":428},": end},\n",[419,1318,1320],{"class":185,"line":1319},69,[419,1321,1322],{"class":428},"        )\n",[419,1324,1326,1329,1331,1334,1337,1340,1343,1346,1349],{"class":185,"line":1325},70,[419,1327,1328],{"class":428},"        points ",[419,1330,432],{"class":424},[419,1332,1333],{"class":428}," [UsagePoint(",[419,1335,1336],{"class":424},"**",[419,1338,1339],{"class":428},"row._mapping) ",[419,1341,1342],{"class":424},"for",[419,1344,1345],{"class":428}," row ",[419,1347,1348],{"class":424},"in",[419,1350,1351],{"class":428}," rows]\n",[419,1353,1355],{"class":185,"line":1354},71,[419,1356,528],{"emptyLinePlaceholder":527},[419,1358,1360,1363,1366,1369,1371,1374,1377,1380],{"class":185,"line":1359},72,[419,1361,1362],{"class":428},"    response.headers[",[419,1364,1365],{"class":435},"\"Cache-Control\"",[419,1367,1368],{"class":428},"] ",[419,1370,432],{"class":424},[419,1372,1373],{"class":424}," f",[419,1375,1376],{"class":435},"\"private, max-age=",[419,1378,1379],{"class":653},"{CACHE_TTL}",[419,1381,1382],{"class":435},"\"\n",[419,1384,1386,1389,1392,1395,1397,1400,1403,1405,1408,1411,1413],{"class":185,"line":1385},73,[419,1387,1388],{"class":424},"    return",[419,1390,1391],{"class":428}," UsageSeries(",[419,1393,1394],{"class":619},"granularity",[419,1396,432],{"class":424},[419,1398,1399],{"class":428},"granularity, ",[419,1401,1402],{"class":619},"as_of",[419,1404,432],{"class":424},[419,1406,1407],{"class":428},"end, ",[419,1409,1410],{"class":619},"points",[419,1412,432],{"class":424},[419,1414,1415],{"class":428},"points)\n",[14,1417,1418,1419,1422,1423,1425,1426,1429,1430,1433],{},"The ",[27,1420,1421],{},".format()"," call on SQL text looks alarming until you notice ",[27,1424,1287],{}," can only ever be one of two literals chosen by the ",[27,1427,1428],{},"match"," statement. Every value that comes from the caller travels as a bound parameter. Keep that discipline: the moment someone adds a ",[27,1431,1432],{},"route LIKE :pattern"," filter, it goes in the parameter dictionary, not the format string.",[14,1435,1436,1437,1439,1440,1444],{},"Note what the endpoint does not do: it never joins to ",[27,1438,41],{},", never computes money, and never accepts a customer identifier. Those three omissions are the whole security model. Confirm the reads also share the pool discipline of the rest of your ",[18,1441,1443],{"href":1442},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F","async SQLAlchemy access"," — a dashboard that opens its own engine is a classic route to pool exhaustion during a spike.",[32,1446,1448],{"id":1447},"quota-progress-customers-actually-trust","Quota progress customers actually trust",[14,1450,1451],{},"The series chart is nice. The quota bar is what people open the page for. It answers one question — how close am I to the wall — and it has to be right, because a customer who is told they used 62% and then gets rate-limited will not trust anything else on the page.",[14,1453,1454],{},"There are two numbers in play and they disagree. Redis holds the live counter your enforcement path increments on every request. Postgres holds the billed truth derived from the durable event log. The Redis number leads by up to one flush interval; the Postgres number lags but is authoritative. Show the live number, label it as live, and reconcile against Postgres whenever the Redis key is missing or the period rolls over.",[410,1456,1458],{"className":491,"code":1457,"language":493,"meta":415,"style":415},"# usage\u002Fquota.py\nimport os\nfrom datetime import datetime, timezone\n\nimport redis.asyncio as redis\nfrom sqlalchemy import text\n\nfrom usage.flusher import Session\n\nclient = redis.from_url(os.environ[\"USAGE_REDIS_URL\"], decode_responses=True)\nALERT_RATIO = float(os.getenv(\"USAGE_QUOTA_ALERT_RATIO\", \"0.8\"))\nPLAN_LIMITS = {\"free\": 1_000, \"starter\": 50_000, \"growth\": 500_000}\n\nBILLED_SQL = \"\"\"\nSELECT coalesce(sum(billable), 0)::bigint\nFROM usage_hourly\nWHERE customer_id = :customer_id AND bucket >= :period_start\n\"\"\"\n\n\ndef _period_start(now: datetime) -> datetime:\n    return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)\n\n\ndef _period_end(now: datetime) -> datetime:\n    year, month = (now.year + 1, 1) if now.month == 12 else (now.year, now.month + 1)\n    return now.replace(year=year, month=month, day=1, hour=0,\n                       minute=0, second=0, microsecond=0)\n\n\nasync def quota_snapshot(customer_id: str, plan: str) -> dict[str, object]:\n    now = datetime.now(timezone.utc)\n    limit = PLAN_LIMITS.get(plan, PLAN_LIMITS[\"free\"])\n    key = f\"quota:{customer_id}:{now:%Y%m}\"\n\n    live = await client.get(key)\n    if live is None:\n        async with Session() as session:\n            result = await session.execute(\n                text(BILLED_SQL),\n                {\"customer_id\": customer_id, \"period_start\": _period_start(now)},\n            )\n            used = int(result.scalar_one())\n        await client.set(key, used, ex=40 * 86_400)\n    else:\n        used = int(live)\n\n    ratio = min(used \u002F limit, 1.0) if limit else 0.0\n    return {\n        \"used\": used,\n        \"limit\": limit,\n        \"percent\": round(ratio * 100, 1),\n        \"resets_at\": _period_end(now).isoformat(),\n        \"alert\": ratio >= ALERT_RATIO,\n        \"source\": \"live\" if live is not None else \"reconciled\",\n    }\n",[27,1459,1460,1465,1471,1482,1486,1498,1508,1512,1522,1526,1552,1574,1616,1620,1629,1634,1638,1643,1647,1651,1655,1666,1715,1719,1723,1732,1779,1818,1845,1849,1853,1885,1895,1916,1959,1963,1975,1990,2003,2014,2023,2038,2043,2055,2079,2086,2098,2102,2137,2144,2152,2160,2185,2193,2209,2238],{"__ignoreMap":415},[419,1461,1462],{"class":185,"line":421},[419,1463,1464],{"class":500},"# usage\u002Fquota.py\n",[419,1466,1467,1469],{"class":185,"line":439},[419,1468,506],{"class":424},[419,1470,509],{"class":428},[419,1472,1473,1475,1477,1479],{"class":185,"line":452},[419,1474,514],{"class":424},[419,1476,517],{"class":428},[419,1478,506],{"class":424},[419,1480,1481],{"class":428}," datetime, timezone\n",[419,1483,1484],{"class":185,"line":465},[419,1485,528],{"emptyLinePlaceholder":527},[419,1487,1488,1490,1493,1495],{"class":185,"line":478},[419,1489,506],{"class":424},[419,1491,1492],{"class":428}," redis.asyncio ",[419,1494,1256],{"class":424},[419,1496,1497],{"class":428}," redis\n",[419,1499,1500,1502,1504,1506],{"class":185,"line":543},[419,1501,514],{"class":424},[419,1503,561],{"class":428},[419,1505,506],{"class":424},[419,1507,566],{"class":428},[419,1509,1510],{"class":185,"line":556},[419,1511,528],{"emptyLinePlaceholder":527},[419,1513,1514,1516,1518,1520],{"class":185,"line":569},[419,1515,514],{"class":424},[419,1517,595],{"class":428},[419,1519,506],{"class":424},[419,1521,600],{"class":428},[419,1523,1524],{"class":185,"line":574},[419,1525,528],{"emptyLinePlaceholder":527},[419,1527,1528,1531,1533,1536,1539,1542,1545,1547,1550],{"class":185,"line":590},[419,1529,1530],{"class":428},"client ",[419,1532,432],{"class":424},[419,1534,1535],{"class":428}," redis.from_url(os.environ[",[419,1537,1538],{"class":435},"\"USAGE_REDIS_URL\"",[419,1540,1541],{"class":428},"], ",[419,1543,1544],{"class":619},"decode_responses",[419,1546,432],{"class":424},[419,1548,1549],{"class":653},"True",[419,1551,1071],{"class":428},[419,1553,1554,1557,1559,1562,1564,1567,1569,1572],{"class":185,"line":603},[419,1555,1556],{"class":653},"ALERT_RATIO",[419,1558,657],{"class":424},[419,1560,1561],{"class":653}," float",[419,1563,663],{"class":428},[419,1565,1566],{"class":435},"\"USAGE_QUOTA_ALERT_RATIO\"",[419,1568,628],{"class":428},[419,1570,1571],{"class":435},"\"0.8\"",[419,1573,674],{"class":428},[419,1575,1576,1579,1581,1584,1587,1590,1593,1595,1598,1600,1603,1605,1608,1610,1613],{"class":185,"line":608},[419,1577,1578],{"class":653},"PLAN_LIMITS",[419,1580,657],{"class":424},[419,1582,1583],{"class":428}," {",[419,1585,1586],{"class":435},"\"free\"",[419,1588,1589],{"class":428},": ",[419,1591,1592],{"class":653},"1_000",[419,1594,628],{"class":428},[419,1596,1597],{"class":435},"\"starter\"",[419,1599,1589],{"class":428},[419,1601,1602],{"class":653},"50_000",[419,1604,628],{"class":428},[419,1606,1607],{"class":435},"\"growth\"",[419,1609,1589],{"class":428},[419,1611,1612],{"class":653},"500_000",[419,1614,1615],{"class":428},"}\n",[419,1617,1618],{"class":185,"line":645},[419,1619,528],{"emptyLinePlaceholder":527},[419,1621,1622,1625,1627],{"class":185,"line":650},[419,1623,1624],{"class":653},"BILLED_SQL",[419,1626,657],{"class":424},[419,1628,712],{"class":435},[419,1630,1631],{"class":185,"line":677},[419,1632,1633],{"class":435},"SELECT coalesce(sum(billable), 0)::bigint\n",[419,1635,1636],{"class":185,"line":699},[419,1637,754],{"class":435},[419,1639,1640],{"class":185,"line":704},[419,1641,1642],{"class":435},"WHERE customer_id = :customer_id AND bucket >= :period_start\n",[419,1644,1645],{"class":185,"line":715},[419,1646,784],{"class":435},[419,1648,1649],{"class":185,"line":727},[419,1650,528],{"emptyLinePlaceholder":527},[419,1652,1653],{"class":185,"line":733},[419,1654,528],{"emptyLinePlaceholder":527},[419,1656,1657,1660,1663],{"class":185,"line":739},[419,1658,1659],{"class":424},"def",[419,1661,1662],{"class":803}," _period_start",[419,1664,1665],{"class":428},"(now: datetime) -> datetime:\n",[419,1667,1668,1670,1673,1676,1678,1680,1682,1685,1687,1689,1691,1693,1695,1697,1699,1701,1703,1705,1707,1709,1711,1713],{"class":185,"line":745},[419,1669,1388],{"class":424},[419,1671,1672],{"class":428}," now.replace(",[419,1674,1675],{"class":619},"day",[419,1677,432],{"class":424},[419,1679,976],{"class":653},[419,1681,628],{"class":428},[419,1683,1684],{"class":619},"hour",[419,1686,432],{"class":424},[419,1688,144],{"class":653},[419,1690,628],{"class":428},[419,1692,1174],{"class":619},[419,1694,432],{"class":424},[419,1696,144],{"class":653},[419,1698,628],{"class":428},[419,1700,1183],{"class":619},[419,1702,432],{"class":424},[419,1704,144],{"class":653},[419,1706,628],{"class":428},[419,1708,1192],{"class":619},[419,1710,432],{"class":424},[419,1712,144],{"class":653},[419,1714,1071],{"class":428},[419,1716,1717],{"class":185,"line":751},[419,1718,528],{"emptyLinePlaceholder":527},[419,1720,1721],{"class":185,"line":757},[419,1722,528],{"emptyLinePlaceholder":527},[419,1724,1725,1727,1730],{"class":185,"line":763},[419,1726,1659],{"class":424},[419,1728,1729],{"class":803}," _period_end",[419,1731,1665],{"class":428},[419,1733,1734,1737,1739,1742,1745,1748,1750,1752,1755,1758,1761,1764,1767,1770,1773,1775,1777],{"class":185,"line":769},[419,1735,1736],{"class":428},"    year, month ",[419,1738,432],{"class":424},[419,1740,1741],{"class":428}," (now.year ",[419,1743,1744],{"class":424},"+",[419,1746,1747],{"class":653}," 1",[419,1749,628],{"class":428},[419,1751,976],{"class":653},[419,1753,1754],{"class":428},") ",[419,1756,1757],{"class":424},"if",[419,1759,1760],{"class":428}," now.month ",[419,1762,1763],{"class":424},"==",[419,1765,1766],{"class":653}," 12",[419,1768,1769],{"class":424}," else",[419,1771,1772],{"class":428}," (now.year, now.month ",[419,1774,1744],{"class":424},[419,1776,1747],{"class":653},[419,1778,1071],{"class":428},[419,1780,1781,1783,1785,1788,1790,1793,1796,1798,1801,1803,1805,1807,1809,1811,1813,1815],{"class":185,"line":775},[419,1782,1388],{"class":424},[419,1784,1672],{"class":428},[419,1786,1787],{"class":619},"year",[419,1789,432],{"class":424},[419,1791,1792],{"class":428},"year, ",[419,1794,1795],{"class":619},"month",[419,1797,432],{"class":424},[419,1799,1800],{"class":428},"month, ",[419,1802,1675],{"class":619},[419,1804,432],{"class":424},[419,1806,976],{"class":653},[419,1808,628],{"class":428},[419,1810,1684],{"class":619},[419,1812,432],{"class":424},[419,1814,144],{"class":653},[419,1816,1817],{"class":428},",\n",[419,1819,1820,1823,1825,1827,1829,1831,1833,1835,1837,1839,1841,1843],{"class":185,"line":781},[419,1821,1822],{"class":619},"                       minute",[419,1824,432],{"class":424},[419,1826,144],{"class":653},[419,1828,628],{"class":428},[419,1830,1183],{"class":619},[419,1832,432],{"class":424},[419,1834,144],{"class":653},[419,1836,628],{"class":428},[419,1838,1192],{"class":619},[419,1840,432],{"class":424},[419,1842,144],{"class":653},[419,1844,1071],{"class":428},[419,1846,1847],{"class":185,"line":787},[419,1848,528],{"emptyLinePlaceholder":527},[419,1850,1851],{"class":185,"line":792},[419,1852,528],{"emptyLinePlaceholder":527},[419,1854,1855,1857,1859,1862,1865,1867,1870,1872,1875,1877,1879,1882],{"class":185,"line":797},[419,1856,935],{"class":424},[419,1858,938],{"class":424},[419,1860,1861],{"class":803}," quota_snapshot",[419,1863,1864],{"class":428},"(customer_id: ",[419,1866,987],{"class":653},[419,1868,1869],{"class":428},", plan: ",[419,1871,987],{"class":653},[419,1873,1874],{"class":428},") -> dict[",[419,1876,987],{"class":653},[419,1878,628],{"class":428},[419,1880,1881],{"class":653},"object",[419,1883,1884],{"class":428},"]:\n",[419,1886,1887,1890,1892],{"class":185,"line":816},[419,1888,1889],{"class":428},"    now ",[419,1891,432],{"class":424},[419,1893,1894],{"class":428}," datetime.now(timezone.utc)\n",[419,1896,1897,1900,1902,1905,1908,1910,1912,1914],{"class":185,"line":822},[419,1898,1899],{"class":428},"    limit ",[419,1901,432],{"class":424},[419,1903,1904],{"class":653}," PLAN_LIMITS",[419,1906,1907],{"class":428},".get(plan, ",[419,1909,1578],{"class":653},[419,1911,636],{"class":428},[419,1913,1586],{"class":435},[419,1915,642],{"class":428},[419,1917,1918,1921,1923,1925,1928,1931,1933,1936,1939,1941,1944,1947,1950,1952,1955,1957],{"class":185,"line":831},[419,1919,1920],{"class":428},"    key ",[419,1922,432],{"class":424},[419,1924,1373],{"class":424},[419,1926,1927],{"class":435},"\"quota:",[419,1929,1930],{"class":653},"{",[419,1932,398],{"class":428},[419,1934,1935],{"class":653},"}",[419,1937,1938],{"class":435},":",[419,1940,1930],{"class":653},[419,1942,1943],{"class":428},"now:",[419,1945,1946],{"class":424},"%",[419,1948,1949],{"class":428},"Y",[419,1951,1946],{"class":424},[419,1953,1954],{"class":428},"m",[419,1956,1935],{"class":653},[419,1958,1382],{"class":435},[419,1960,1961],{"class":185,"line":839},[419,1962,528],{"emptyLinePlaceholder":527},[419,1964,1965,1968,1970,1972],{"class":185,"line":847},[419,1966,1967],{"class":428},"    live ",[419,1969,432],{"class":424},[419,1971,1270],{"class":424},[419,1973,1974],{"class":428}," client.get(key)\n",[419,1976,1977,1979,1982,1985,1988],{"class":185,"line":856},[419,1978,1021],{"class":424},[419,1980,1981],{"class":428}," live ",[419,1983,1984],{"class":424},"is",[419,1986,1987],{"class":653}," None",[419,1989,1033],{"class":428},[419,1991,1992,1995,1997,1999,2001],{"class":185,"line":861},[419,1993,1994],{"class":424},"        async",[419,1996,1250],{"class":424},[419,1998,1253],{"class":428},[419,2000,1256],{"class":424},[419,2002,1259],{"class":428},[419,2004,2005,2008,2010,2012],{"class":185,"line":866},[419,2006,2007],{"class":428},"            result ",[419,2009,432],{"class":424},[419,2011,1270],{"class":424},[419,2013,1273],{"class":428},[419,2015,2016,2019,2021],{"class":185,"line":880},[419,2017,2018],{"class":428},"                text(",[419,2020,1624],{"class":653},[419,2022,979],{"class":428},[419,2024,2025,2028,2030,2032,2035],{"class":185,"line":889},[419,2026,2027],{"class":428},"                {",[419,2029,1301],{"class":435},[419,2031,1304],{"class":428},[419,2033,2034],{"class":435},"\"period_start\"",[419,2036,2037],{"class":428},": _period_start(now)},\n",[419,2039,2040],{"class":185,"line":895},[419,2041,2042],{"class":428},"            )\n",[419,2044,2045,2048,2050,2052],{"class":185,"line":901},[419,2046,2047],{"class":428},"            used ",[419,2049,432],{"class":424},[419,2051,660],{"class":653},[419,2053,2054],{"class":428},"(result.scalar_one())\n",[419,2056,2057,2060,2063,2066,2068,2071,2074,2077],{"class":185,"line":906},[419,2058,2059],{"class":424},"        await",[419,2061,2062],{"class":428}," client.set(key, used, ",[419,2064,2065],{"class":619},"ex",[419,2067,432],{"class":424},[419,2069,2070],{"class":653},"40",[419,2072,2073],{"class":424}," *",[419,2075,2076],{"class":653}," 86_400",[419,2078,1071],{"class":428},[419,2080,2081,2084],{"class":185,"line":911},[419,2082,2083],{"class":424},"    else",[419,2085,1033],{"class":428},[419,2087,2088,2091,2093,2095],{"class":185,"line":932},[419,2089,2090],{"class":428},"        used ",[419,2092,432],{"class":424},[419,2094,660],{"class":653},[419,2096,2097],{"class":428},"(live)\n",[419,2099,2100],{"class":185,"line":947},[419,2101,528],{"emptyLinePlaceholder":527},[419,2103,2104,2107,2109,2112,2115,2118,2121,2124,2126,2128,2131,2134],{"class":185,"line":953},[419,2105,2106],{"class":428},"    ratio ",[419,2108,432],{"class":424},[419,2110,2111],{"class":653}," min",[419,2113,2114],{"class":428},"(used ",[419,2116,2117],{"class":424},"\u002F",[419,2119,2120],{"class":428}," limit, ",[419,2122,2123],{"class":653},"1.0",[419,2125,1754],{"class":428},[419,2127,1757],{"class":424},[419,2129,2130],{"class":428}," limit ",[419,2132,2133],{"class":424},"else",[419,2135,2136],{"class":653}," 0.0\n",[419,2138,2139,2141],{"class":185,"line":982},[419,2140,1388],{"class":424},[419,2142,2143],{"class":428}," {\n",[419,2145,2146,2149],{"class":185,"line":999},[419,2147,2148],{"class":435},"        \"used\"",[419,2150,2151],{"class":428},": used,\n",[419,2153,2154,2157],{"class":185,"line":1012},[419,2155,2156],{"class":435},"        \"limit\"",[419,2158,2159],{"class":428},": limit,\n",[419,2161,2162,2165,2167,2170,2173,2176,2179,2181,2183],{"class":185,"line":1018},[419,2163,2164],{"class":435},"        \"percent\"",[419,2166,1589],{"class":428},[419,2168,2169],{"class":653},"round",[419,2171,2172],{"class":428},"(ratio ",[419,2174,2175],{"class":424},"*",[419,2177,2178],{"class":653}," 100",[419,2180,628],{"class":428},[419,2182,976],{"class":653},[419,2184,979],{"class":428},[419,2186,2187,2190],{"class":185,"line":1036},[419,2188,2189],{"class":435},"        \"resets_at\"",[419,2191,2192],{"class":428},": _period_end(now).isoformat(),\n",[419,2194,2195,2198,2201,2204,2207],{"class":185,"line":1074},[419,2196,2197],{"class":435},"        \"alert\"",[419,2199,2200],{"class":428},": ratio ",[419,2202,2203],{"class":424},">=",[419,2205,2206],{"class":653}," ALERT_RATIO",[419,2208,1817],{"class":428},[419,2210,2211,2214,2216,2219,2222,2224,2226,2229,2231,2233,2236],{"class":185,"line":1083},[419,2212,2213],{"class":435},"        \"source\"",[419,2215,1589],{"class":428},[419,2217,2218],{"class":435},"\"live\"",[419,2220,2221],{"class":424}," if",[419,2223,1981],{"class":428},[419,2225,1984],{"class":424},[419,2227,2228],{"class":424}," not",[419,2230,1987],{"class":653},[419,2232,1769],{"class":424},[419,2234,2235],{"class":435}," \"reconciled\"",[419,2237,1817],{"class":428},[419,2239,2240],{"class":185,"line":1094},[419,2241,2242],{"class":428},"    }\n",[14,2244,2245,2246,2248,2249,2252,2253,2257],{},"The rebuild-on-miss branch is the important part. A Redis restart wipes the counter, and without that fallback every customer sees zero usage afterwards — which reads as a billing bug even though nothing was lost. Rebuilding from ",[27,2247,45],{}," costs one indexed query and self-heals the key for the rest of the month. Expose ",[27,2250,2251],{},"alert"," so the UI turns the bar amber at 80% and offers an upgrade link; that pairs directly with the tier ladder from ",[18,2254,2256],{"href":2255},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fstripe-metered-billing-configuration\u002F","Stripe metered billing configuration",".",[123,2259,132,2263,132,2266,132,2269,132,2271,132,2274,132,2280,132,2283,132,2288,132,2292,132,2295,132,2300,132,2304,132,2307,132,2311,132,2315,132,2319,132,2322,132,2325,132,2328,132,2334],{"viewBox":284,"role":126,"ariaLabelledBy":2260,"xmlns":130,"style":131},[2261,2262],"cud-quota-t","cud-quota-d",[134,2264,2265],{"id":2261},"Quota progress bar with live and reconciled counter tracks",[138,2267,2268],{"id":2262},"A progress bar shows 34,200 of 50,000 calls used with an alert threshold at eighty percent, above two tracks comparing the live Redis counter and the reconciled Postgres total.",[142,2270],{"x":144,"y":144,"width":296,"height":297,"fill":147},[177,2272,2273],{"x":2070,"y":2070,"fill":181,"style":301},"Starter plan: 34,200 of 50,000 calls this period",[142,2275],{"x":2070,"y":2276,"width":2277,"height":2278,"rx":2279,"fill":208,"stroke":355,"style":356},"58","680","34","8",[142,2281],{"x":2070,"y":2276,"width":2282,"height":2278,"rx":2279,"fill":174},"465",[185,2284],{"x1":215,"y1":2285,"x2":215,"y2":2286,"stroke":234,"style":2287},"50","104","stroke-width:2;stroke-dasharray:5 4;",[177,2289,2291],{"x":215,"y":2290,"fill":234,"style":221},"120","80% alert",[177,2293,144],{"x":2070,"y":2294,"fill":166,"style":239},"140",[177,2296,2299],{"x":2297,"y":2294,"fill":166,"style":2298},"720","text-anchor:end;font-size:11;font-family:var(--font-sans);","50,000",[177,2301,2303],{"x":2070,"y":2302,"fill":181,"style":316},"180","Live counter",[142,2305],{"x":2302,"y":2306,"width":310,"height":312,"rx":313,"fill":208,"stroke":257,"style":175},"162",[177,2308,2310],{"x":2309,"y":2302,"fill":181,"style":221},"280","Redis: 34,200",[177,2312,2314],{"x":2313,"y":2302,"fill":166,"style":239},"396","leads by one flush interval",[177,2316,2318],{"x":2070,"y":2317,"fill":181,"style":316},"228","Billed truth",[142,2320],{"x":2302,"y":2321,"width":310,"height":312,"rx":313,"fill":208,"stroke":195,"style":175},"210",[177,2323,2324],{"x":2309,"y":2317,"fill":181,"style":221},"Postgres: 34,187",[177,2326,2327],{"x":2313,"y":2317,"fill":166,"style":239},"rebuilds the key when it is lost",[142,2329],{"x":2302,"y":2330,"width":2331,"height":2332,"rx":2333,"fill":147,"stroke":355,"style":356},"252","330","28","6",[177,2335,2338],{"x":2336,"y":2337,"fill":181,"style":221},"345","271","drift 13 calls (0.04%) — show live, bill truth",[32,2340,2342],{"id":2341},"caching-without-lying-about-freshness","Caching without lying about freshness",[14,2344,2345,2346,2350],{},"Sixty seconds of cache absorbs the refresh-key-mashing that dashboards attract, and it does so without any invalidation logic at all — the data is append-only, so a short time-to-live is strictly correct. That is the rare case where TTL beats event-driven invalidation; the trade-offs are laid out properly in ",[18,2347,2349],{"href":2348},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002Fcache-invalidation-strategies\u002F","cache invalidation strategies",", but for an append-only series you should not build anything cleverer.",[14,2352,2353,2354,2356],{},"Key the cache on the customer, the granularity, and the range, then hash it so a long key never bloats Redis. Always ship an ",[27,2355,1402],{}," timestamp inside the payload and render it in the UI as \"updated 40 seconds ago\". A stale number with an honest timestamp is trustworthy; a stale number presented as live is a support ticket.",[410,2358,2360],{"className":491,"code":2359,"language":493,"meta":415,"style":415},"# usage\u002Fcache.py\nimport hashlib\nimport json\nimport os\nfrom collections.abc import Awaitable, Callable\nfrom typing import Any\n\nimport redis.asyncio as redis\n\nclient = redis.from_url(os.environ[\"USAGE_REDIS_URL\"], decode_responses=True)\nTTL = int(os.getenv(\"USAGE_DASHBOARD_CACHE_TTL\", \"60\"))\n\n\nasync def cached_json(\n    parts: tuple[str, ...],\n    build: Callable[[], Awaitable[dict[str, Any]]],\n) -> tuple[dict[str, Any], str, str]:\n    digest = hashlib.sha256(\"|\".join(parts).encode()).hexdigest()\n    key = f\"dash:{digest[:24]}\"\n    hit = await client.get(key)\n    if hit is not None:\n        return json.loads(hit), \"HIT\", f'W\u002F\"{digest[:16]}\"'\n    payload = await build()\n    body = json.dumps(payload, default=str, sort_keys=True)\n    await client.set(key, body, ex=TTL)\n    etag = hashlib.sha256(body.encode()).hexdigest()[:16]\n    return payload, \"MISS\", f'W\u002F\"{etag}\"'\n",[27,2361,2362,2367,2374,2381,2387,2399,2411,2415,2425,2429,2449,2468,2472,2476,2487,2502,2512,2530,2546,2571,2582,2597,2629,2641,2669,2685,2700],{"__ignoreMap":415},[419,2363,2364],{"class":185,"line":421},[419,2365,2366],{"class":500},"# usage\u002Fcache.py\n",[419,2368,2369,2371],{"class":185,"line":439},[419,2370,506],{"class":424},[419,2372,2373],{"class":428}," hashlib\n",[419,2375,2376,2378],{"class":185,"line":452},[419,2377,506],{"class":424},[419,2379,2380],{"class":428}," json\n",[419,2382,2383,2385],{"class":185,"line":465},[419,2384,506],{"class":424},[419,2386,509],{"class":428},[419,2388,2389,2391,2394,2396],{"class":185,"line":478},[419,2390,514],{"class":424},[419,2392,2393],{"class":428}," collections.abc ",[419,2395,506],{"class":424},[419,2397,2398],{"class":428}," Awaitable, Callable\n",[419,2400,2401,2403,2406,2408],{"class":185,"line":543},[419,2402,514],{"class":424},[419,2404,2405],{"class":428}," typing ",[419,2407,506],{"class":424},[419,2409,2410],{"class":428}," Any\n",[419,2412,2413],{"class":185,"line":556},[419,2414,528],{"emptyLinePlaceholder":527},[419,2416,2417,2419,2421,2423],{"class":185,"line":569},[419,2418,506],{"class":424},[419,2420,1492],{"class":428},[419,2422,1256],{"class":424},[419,2424,1497],{"class":428},[419,2426,2427],{"class":185,"line":574},[419,2428,528],{"emptyLinePlaceholder":527},[419,2430,2431,2433,2435,2437,2439,2441,2443,2445,2447],{"class":185,"line":590},[419,2432,1530],{"class":428},[419,2434,432],{"class":424},[419,2436,1535],{"class":428},[419,2438,1538],{"class":435},[419,2440,1541],{"class":428},[419,2442,1544],{"class":619},[419,2444,432],{"class":424},[419,2446,1549],{"class":653},[419,2448,1071],{"class":428},[419,2450,2451,2454,2456,2458,2460,2462,2464,2466],{"class":185,"line":603},[419,2452,2453],{"class":653},"TTL",[419,2455,657],{"class":424},[419,2457,660],{"class":653},[419,2459,663],{"class":428},[419,2461,689],{"class":435},[419,2463,628],{"class":428},[419,2465,694],{"class":435},[419,2467,674],{"class":428},[419,2469,2470],{"class":185,"line":608},[419,2471,528],{"emptyLinePlaceholder":527},[419,2473,2474],{"class":185,"line":645},[419,2475,528],{"emptyLinePlaceholder":527},[419,2477,2478,2480,2482,2485],{"class":185,"line":650},[419,2479,935],{"class":424},[419,2481,938],{"class":424},[419,2483,2484],{"class":803}," cached_json",[419,2486,944],{"class":428},[419,2488,2489,2492,2494,2496,2499],{"class":185,"line":677},[419,2490,2491],{"class":428},"    parts: tuple[",[419,2493,987],{"class":653},[419,2495,628],{"class":428},[419,2497,2498],{"class":653},"...",[419,2500,2501],{"class":428},"],\n",[419,2503,2504,2507,2509],{"class":185,"line":699},[419,2505,2506],{"class":428},"    build: Callable[[], Awaitable[dict[",[419,2508,987],{"class":653},[419,2510,2511],{"class":428},", Any]]],\n",[419,2513,2514,2517,2519,2522,2524,2526,2528],{"class":185,"line":704},[419,2515,2516],{"class":428},") -> tuple[dict[",[419,2518,987],{"class":653},[419,2520,2521],{"class":428},", Any], ",[419,2523,987],{"class":653},[419,2525,628],{"class":428},[419,2527,987],{"class":653},[419,2529,1884],{"class":428},[419,2531,2532,2535,2537,2540,2543],{"class":185,"line":715},[419,2533,2534],{"class":428},"    digest ",[419,2536,432],{"class":424},[419,2538,2539],{"class":428}," hashlib.sha256(",[419,2541,2542],{"class":435},"\"|\"",[419,2544,2545],{"class":428},".join(parts).encode()).hexdigest()\n",[419,2547,2548,2550,2552,2554,2557,2559,2562,2564,2567,2569],{"class":185,"line":727},[419,2549,1920],{"class":428},[419,2551,432],{"class":424},[419,2553,1373],{"class":424},[419,2555,2556],{"class":435},"\"dash:",[419,2558,1930],{"class":653},[419,2560,2561],{"class":428},"digest[:",[419,2563,169],{"class":653},[419,2565,2566],{"class":428},"]",[419,2568,1935],{"class":653},[419,2570,1382],{"class":435},[419,2572,2573,2576,2578,2580],{"class":185,"line":733},[419,2574,2575],{"class":428},"    hit ",[419,2577,432],{"class":424},[419,2579,1270],{"class":424},[419,2581,1974],{"class":428},[419,2583,2584,2586,2589,2591,2593,2595],{"class":185,"line":739},[419,2585,1021],{"class":424},[419,2587,2588],{"class":428}," hit ",[419,2590,1984],{"class":424},[419,2592,2228],{"class":424},[419,2594,1987],{"class":653},[419,2596,1033],{"class":428},[419,2598,2599,2602,2605,2608,2610,2612,2615,2617,2619,2622,2624,2626],{"class":185,"line":745},[419,2600,2601],{"class":424},"        return",[419,2603,2604],{"class":428}," json.loads(hit), ",[419,2606,2607],{"class":435},"\"HIT\"",[419,2609,628],{"class":428},[419,2611,1059],{"class":424},[419,2613,2614],{"class":435},"'W\u002F\"",[419,2616,1930],{"class":653},[419,2618,2561],{"class":428},[419,2620,2621],{"class":653},"16",[419,2623,2566],{"class":428},[419,2625,1935],{"class":653},[419,2627,2628],{"class":435},"\"'\n",[419,2630,2631,2634,2636,2638],{"class":185,"line":751},[419,2632,2633],{"class":428},"    payload ",[419,2635,432],{"class":424},[419,2637,1270],{"class":424},[419,2639,2640],{"class":428}," build()\n",[419,2642,2643,2646,2648,2651,2654,2656,2658,2660,2663,2665,2667],{"class":185,"line":757},[419,2644,2645],{"class":428},"    body ",[419,2647,432],{"class":424},[419,2649,2650],{"class":428}," json.dumps(payload, ",[419,2652,2653],{"class":619},"default",[419,2655,432],{"class":424},[419,2657,987],{"class":653},[419,2659,628],{"class":428},[419,2661,2662],{"class":619},"sort_keys",[419,2664,432],{"class":424},[419,2666,1549],{"class":653},[419,2668,1071],{"class":428},[419,2670,2671,2674,2677,2679,2681,2683],{"class":185,"line":763},[419,2672,2673],{"class":424},"    await",[419,2675,2676],{"class":428}," client.set(key, body, ",[419,2678,2065],{"class":619},[419,2680,432],{"class":424},[419,2682,2453],{"class":653},[419,2684,1071],{"class":428},[419,2686,2687,2690,2692,2695,2697],{"class":185,"line":769},[419,2688,2689],{"class":428},"    etag ",[419,2691,432],{"class":424},[419,2693,2694],{"class":428}," hashlib.sha256(body.encode()).hexdigest()[:",[419,2696,2621],{"class":653},[419,2698,2699],{"class":428},"]\n",[419,2701,2702,2704,2707,2710,2712,2714,2716,2718,2721,2723],{"class":185,"line":775},[419,2703,1388],{"class":424},[419,2705,2706],{"class":428}," payload, ",[419,2708,2709],{"class":435},"\"MISS\"",[419,2711,628],{"class":428},[419,2713,1059],{"class":424},[419,2715,2614],{"class":435},[419,2717,1930],{"class":653},[419,2719,2720],{"class":428},"etag",[419,2722,1935],{"class":653},[419,2724,2628],{"class":435},[14,2726,2727,2728,2731,2732,2735,2736,2739],{},"Return the weak ETag and answer ",[27,2729,2730],{},"304 Not Modified"," when the client sends a matching ",[27,2733,2734],{},"If-None-Match",". A dashboard polling every thirty seconds then transfers a few hundred bytes of headers instead of a 40 KB JSON body — real savings once you pay egress on a metered host. Add ",[27,2737,2738],{},"Cache-Control: private"," so no shared proxy ever stores one customer's numbers.",[32,2741,2743],{"id":2742},"what-to-expose-and-what-to-hide","What to expose and what to hide",[14,2745,2746],{},"This is where builders leak. Usage data sits next to cost data in the same tables, and it is trivially easy to hand a customer your gross margin by shipping one extra column. Draw the line explicitly.",[123,2748,132,2753,132,2756,132,2759,132,2761,132,2764,132,2768,132,2772,132,2776,132,2778,132,2782,132,2785,132,2789,132,2791,132,2795,132,2798,132,2802,132,2804,132,2807,132,2809,132,2812,132,2814,132,2817,132,2819,132,2822,132,2824,132,2827],{"viewBox":2749,"role":126,"ariaLabelledBy":2750,"xmlns":130,"style":131},"0 0 760 340",[2751,2752],"cud-expose-t","cud-expose-d",[134,2754,2755],{"id":2751},"Fields to expose in a customer dashboard versus fields to hide",[138,2757,2758],{"id":2752},"Two columns of labelled chips: exposed fields such as calls by day, quota progress, error rate, latency and billable units; hidden fields such as internal cost per call, infrastructure names, other tenants, raw payloads and margin.",[142,2760],{"x":144,"y":144,"width":296,"height":146,"fill":147},[177,2762,2763],{"x":2070,"y":2070,"fill":195,"style":301},"Expose",[177,2765,2767],{"x":2766,"y":2070,"fill":234,"style":301},"400","Hide",[142,2769],{"x":2070,"y":2770,"width":297,"height":2771,"rx":2333,"fill":208,"stroke":195,"style":175},"56","38",[177,2773,2775],{"x":248,"y":2774,"fill":181,"style":221},"80","Calls by day and endpoint",[142,2777],{"x":2070,"y":2286,"width":297,"height":2771,"rx":2333,"fill":208,"stroke":195,"style":175},[177,2779,2781],{"x":248,"y":2780,"fill":181,"style":221},"128","Quota used, limit, reset date",[142,2783],{"x":2070,"y":2784,"width":297,"height":2771,"rx":2333,"fill":208,"stroke":195,"style":175},"152",[177,2786,2788],{"x":248,"y":2787,"fill":181,"style":221},"176","Their own 4xx and 5xx counts",[142,2790],{"x":2070,"y":310,"width":297,"height":2771,"rx":2333,"fill":208,"stroke":195,"style":175},[177,2792,2794],{"x":248,"y":2793,"fill":181,"style":221},"224","Average latency you commit to",[142,2796],{"x":2070,"y":2797,"width":297,"height":2771,"rx":2333,"fill":208,"stroke":195,"style":175},"248",[177,2799,2801],{"x":248,"y":2800,"fill":181,"style":221},"272","Billable units and as_of time",[142,2803],{"x":2766,"y":2770,"width":363,"height":2771,"rx":2333,"fill":208,"stroke":234,"style":175},[177,2805,2806],{"x":371,"y":2774,"fill":181,"style":221},"Internal cost per request",[142,2808],{"x":2766,"y":2286,"width":363,"height":2771,"rx":2333,"fill":208,"stroke":234,"style":175},[177,2810,2811],{"x":371,"y":2780,"fill":181,"style":221},"Host names, regions, pod ids",[142,2813],{"x":2766,"y":2784,"width":363,"height":2771,"rx":2333,"fill":208,"stroke":234,"style":175},[177,2815,2816],{"x":371,"y":2787,"fill":181,"style":221},"Any other account's totals",[142,2818],{"x":2766,"y":310,"width":363,"height":2771,"rx":2333,"fill":208,"stroke":234,"style":175},[177,2820,2821],{"x":371,"y":2793,"fill":181,"style":221},"Request bodies and payload PII",[142,2823],{"x":2766,"y":2797,"width":363,"height":2771,"rx":2333,"fill":208,"stroke":234,"style":175},[177,2825,2826],{"x":371,"y":2800,"fill":181,"style":221},"Upstream vendor names, margin",[177,2828,2830],{"x":2070,"y":2829,"fill":166,"style":239},"316","Rule: if a competitor could price against it, it does not belong in the customer payload.",[14,2832,2833,2834,2838],{},"Two subtleties. Hide your upstream vendors: if an endpoint costs you $0.004 because you resell a model provider, publishing per-call cost tells a customer your markup and who to call instead — keep it in the internal margin report from ",[18,2835,2837],{"href":2836},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fcalculating-cost-per-api-request\u002F","calculating cost per API request",". And always split error counts by class: showing that 5,900 calls were 4xx and only 12 were 5xx converts \"your API is broken\" into \"my integration has a bug\" before anyone opens a ticket.",[32,2840,2842],{"id":2841},"when-to-build-this-and-when-to-skip-it","When to build this, and when to skip it",[14,2844,2845],{},"Build the dashboard when any of these is true: you charge by usage rather than by seat, you have more than roughly twenty paying accounts, or you have answered the same \"how much have I used\" email twice in a month. Usage-priced products need it earliest, because the customer's spend is unpredictable and a hidden meter feels like a taxi with the display taped over.",[14,2847,2848],{},"Skip it, for now, if you are pre-revenue or under about ten customers. At that scale a saved SQL query and a two-line reply is faster and gives you conversations you actually want to be having. Skip it too if your traffic is so low that raw scans finish in 20 ms — you do not need rollups until a customer's month exceeds roughly 200,000 events, and premature aggregation just gives you a rollup job to babysit.",[14,2850,2851,2852,2856],{},"The threshold that forces the rollup path is easy to watch: track the dashboard endpoint's p95 in your ",[18,2853,2855],{"href":2854},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002F","monitoring and logging setup"," and move within the week once it crosses 300 ms. The curve is superlinear, so you have less runway than the graph suggests.",[32,2858,2860],{"id":2859},"migrating-from-raw-queries-to-rollups","Migrating from raw queries to rollups",[14,2862,2863],{},"If you already shipped a dashboard that scans raw events, the switch takes an afternoon and needs no downtime.",[2865,2866,2867,2878,2881,2884,2894,2897],"ol",{},[2868,2869,2870,2871,2873,2874,2877],"li",{},"Create ",[27,2872,45],{}," and backfill it once with the same aggregation the scheduled job runs, chunked by day so a single statement never locks for minutes: ",[27,2875,2876],{},"INSERT INTO usage_hourly SELECT ... WHERE occurred_at >= :day AND occurred_at \u003C :day + interval '1 day'"," looped over your retention window.",[2868,2879,2880],{},"Start the rollup job on its ten-minute schedule and let it run for an hour alongside the existing dashboard.",[2868,2882,2883],{},"Verify equivalence: for five real customers, compare the raw-scan total and the rollup total for the last complete day. They must match exactly. If they differ, your rollup window is dropping late events, not your maths.",[2868,2885,2886,2887,2889,2890,2893],{},"Flip the endpoint to read ",[27,2888,45],{}," behind a feature flag from ",[27,2891,2892],{},"os.getenv",", and keep the raw path available for one week.",[2868,2895,2896],{},"Add the cache last, once the numbers are proven. Caching a wrong answer just makes it wrong for longer.",[2868,2898,2899,2900,2904],{},"Shorten raw retention only after two clean billing periods, and never before your ",[18,2901,2903],{"href":2902},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Flogging-api-usage-events-to-postgres\u002F","usage event log in Postgres"," has survived a real dispute.",[32,2906,2908],{"id":2907},"builder-verdict","Builder verdict",[14,2910,2911,2912,2916],{},"Pre-aggregated rollups behind a 60-second cache win, and it is not close. The raw-scan dashboard is seductive because it is fifteen lines of code and always fresh, but it converts your cheapest support feature into your most expensive query. The rollup path costs one extra table, one cron entry, and a cache helper — three hours of work — and it delivers a 3 ms response that stays 3 ms whether the customer did 10,000 calls or 10 million. Ship the JSON endpoint first, render a server-side page over it with no client framework at all, and put the quota bar with its 80% alert above the fold. That single bar deflects tickets and sells upgrades, which beats the return on most weeks of feature work. Then publish the same endpoint in your ",[18,2913,2915],{"href":2914},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fcreating-a-developer-portal-for-your-api\u002F","developer portal"," and let customers build their own views on it.",[32,2918,2920],{"id":2919},"faq","FAQ",[14,2922,2923,2927],{},[2924,2925,2926],"strong",{},"How much does a customer usage dashboard cost to run at 1M requests a month?","\nEffectively nothing beyond what the tracking pipeline already costs. The rollup job for 1M events runs in under two seconds every ten minutes, the cached endpoint serves from Redis in about 3 ms, and the cache holds a few kilobytes per active customer. Budget under a dollar a month of compute. The raw-scan alternative is what costs money: sustained sequential scans on a managed Postgres instance push you into the next instance tier far sooner.",[14,2929,2930,2933,2934,2936],{},[2924,2931,2932],{},"Should customers see the same usage number I bill them from?","\nBill from the Postgres event log, display the live Redis counter, and label the display as live with an ",[27,2935,1402],{}," timestamp. The two will differ by a fraction of a percent inside a flush interval. What you must never do is invoice from the cached dashboard number — a cache that survives a bad deploy would then bake an error into a real invoice.",[14,2938,2939,2942],{},[2924,2940,2941],{},"Does exposing usage data increase my churn risk?","\nIt reduces it. Customers who can see consumption trends catch their own runaway loops, and the 80% quota alert converts to upgrades rather than surprise overage disputes. The only genuine risk is exposing cost or margin fields, which tells a customer exactly how much room they have to negotiate or to rebuild the thing themselves.",[14,2944,2945,2948,2949,2951],{},[2924,2946,2947],{},"How do I keep one customer from seeing another's numbers?","\nScope every query by the identity resolved from the presented credential and never accept a customer identifier as a parameter. Include the customer id in the cache key so a cache collision cannot cross the boundary, set ",[27,2950,2738],{},", and write one integration test that calls the endpoint with customer A's key while requesting a range in which customer B has traffic, asserting the totals match A's alone.",[14,2953,2954,2957,2958,2960],{},[2924,2955,2956],{},"What is the migration risk if I already have a raw-query dashboard?","\nLow, because both paths can run simultaneously. Backfill the rollup, run the job for an hour, and reconcile five customers' daily totals against the raw scan before flipping a ",[27,2959,2892],{}," flag. Keep the raw path warm for a week. The only irreversible step is shortening raw retention, so leave that until two billing periods have closed cleanly.",[32,2962,2964],{"id":2963},"related","Related",[2966,2967,2968,2973,2979,2986,2992],"ul",{},[2868,2969,2970,2972],{},[18,2971,21],{"href":20}," — the event pipeline, Redis counters, and rollup job this dashboard reads from.",[2868,2974,2975,2978],{},[18,2976,2977],{"href":2902},"Logging API Usage Events to Postgres"," — partitioning and index tuning that keeps the rollup job under two seconds.",[2868,2980,2981,2985],{},[18,2982,2984],{"href":2983},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","Caching Python API Responses with Redis"," — the caching layer behind the 60-second dashboard cache, with pooling and serialization details.",[2868,2987,2988,2991],{},[18,2989,2990],{"href":2836},"Calculating Cost per API Request"," — the internal margin numbers that must stay out of the customer payload.",[2868,2993,2994,2998],{},[18,2995,2997],{"href":2996},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Ffixing-connection-pool-exhaustion\u002F","Fixing Connection Pool Exhaustion"," — what happens when dashboard reads open their own engine during a traffic spike.",[3000,3001,3002],"style",{},"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 .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 .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}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":415,"searchDepth":439,"depth":439,"links":3004},[3005,3006,3007,3008,3009,3010,3011,3012,3013,3014],{"id":34,"depth":439,"text":35},{"id":379,"depth":439,"text":380},{"id":1447,"depth":439,"text":1448},{"id":2341,"depth":439,"text":2342},{"id":2742,"depth":439,"text":2743},{"id":2841,"depth":439,"text":2842},{"id":2859,"depth":439,"text":2860},{"id":2907,"depth":439,"text":2908},{"id":2919,"depth":439,"text":2920},{"id":2963,"depth":439,"text":2964},"Ship a read-only usage endpoint and self-serve dashboard: pre-aggregated rollups, live quota progress, cached queries, and what to expose versus hide.","md",{"pageTitle":3018,"type":3019,"datePublished":3020,"dateModified":3020},"Building a Customer Usage Dashboard for Your Python API","article","2026-07-23","\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Fbuilding-a-customer-usage-dashboard",{"title":5,"description":3015},"building-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Fbuilding-a-customer-usage-dashboard\u002Findex","rTJkTsjC8Fk-E5KQwB3EnXwvXxRTpjY8vKejPT98JBc",{"@context":3026,"@type":3027,"mainEntity":3028},"https:\u002F\u002Fschema.org","FAQPage",[3029,3034,3037,3040,3043],{"@type":3030,"name":2926,"acceptedAnswer":3031},"Question",{"@type":3032,"text":3033},"Answer","Effectively nothing beyond what the tracking pipeline already costs. The rollup job for 1M events runs in under two seconds every ten minutes, the cached endpoint serves from Redis in about 3 ms, and the cache holds a few kilobytes per active customer. Budget under a dollar a month of compute. The raw-scan alternative is what costs money: sustained sequential scans on a managed Postgres instance push you into the next instance tier far sooner.",{"@type":3030,"name":2932,"acceptedAnswer":3035},{"@type":3032,"text":3036},"Bill from the Postgres event log, display the live Redis counter, and label the display as live with an as_of timestamp. The two will differ by a fraction of a percent inside a flush interval. What you must never do is invoice from the cached dashboard number — a cache that survives a bad deploy would then bake an error into a real invoice.",{"@type":3030,"name":2941,"acceptedAnswer":3038},{"@type":3032,"text":3039},"It reduces it. Customers who can see consumption trends catch their own runaway loops, and the 80% quota alert converts to upgrades rather than surprise overage disputes. The only genuine risk is exposing cost or margin fields, which tells a customer exactly how much room they have to negotiate or to rebuild the thing themselves.",{"@type":3030,"name":2947,"acceptedAnswer":3041},{"@type":3032,"text":3042},"Scope every query by the identity resolved from the presented credential and never accept a customer identifier as a parameter. Include the customer id in the cache key so a cache collision cannot cross the boundary, set Cache-Control: private, and write one integration test that calls the endpoint with customer A's key while requesting a range in which customer B has traffic, asserting the totals match A's alone.",{"@type":3030,"name":2956,"acceptedAnswer":3044},{"@type":3032,"text":3045},"Low, because both paths can run simultaneously. Backfill the rollup, run the job for an hour, and reconcile five customers' daily totals against the raw scan before flipping a os.getenv flag. Keep the raw path warm for a week. The only irreversible step is shortening raw retention, so leave that until two billing periods have closed cleanly.",1784887028388]