Indie LLM Cost Meter Starter (CSV + GSheets + SQL)
A vendor-agnostic, copy‑paste starter to log LLM usage/costs, see a weekly digest, and trigger budget alerts. Includes CSV/Sheet schema, Apps Script webhook, Looker Studio layout, DuckDB/Parquet SQL, and a Slack alert recipe.
Ship a vendor-agnostic usage/cost meter in under an hour. Start spreadsheet-first, grow into Parquet/SQL, and wire budget alerts that protect your margins while you travel. This template gives you: a default schema, a ready-to-paste Apps Script webhook, a Looker Studio digest outline, DuckDB/Parquet SQL for daily and 7‑day burn, and a Slack alert recipe. Copy, fill the [BRACKETS], and deploy.
Default schema (CSV/Sheet header + field notes)
Copy this header row into a CSV or your Google Sheet tab (A1):
event_id,timestamp,actor_type,provider,model,region,input_tokens,output_tokens,latency_ms,cost_usd,eval_score,confidence,job_id,customer_id,pii_flag,review_required,reviewer_id,decision,error_code
Field notes and defaults:
- event_id: UUIDv4. If missing, generate server-side.
- timestamp: RFC3339 UTC (e.g.,
2026-05-01T18:04:23Z). - actor_type: one of
user|agent|human-reviewer. - provider/model/region: normalized lowercase (see naming section).
- input_tokens/output_tokens: integers from provider
usageobjects when available. - latency_ms: integer (round-trip).
- cost_usd: decimal; compute from model pricing if response didn’t include cost.
- eval_score/confidence: decimals 0–1 for internal quality checks (optional).
- job_id/customer_id: your attribution keys.
- pii_flag: boolean (
true|false); settrueif raw text contained PII (see redaction). - review_required: boolean;
trueif routed to human. - reviewer_id: ID/email handle for human reviewer (if applicable).
- decision: enum
approved|rejected|edited|n/a. - error_code: short code for
429,RATE_LIMIT,TIMEOUT, etc.
Mini example row (copy into your CSV to test ingestion):
0d9a7f9a-1f5f-42a0-9c2d-1c9b7d6e2a31,2026-05-01T18:04:23Z,agent,openai,gpt-5.4-mini,us-east-1,1240,318,1420,0.0021,0.78,0.92,job_8F3A,cus_12KD,false,false,,,
Google Sheet tab setup (events + config)
- Create a Google Sheet named
[LLM Cost Meter]. - Add a tab
[TAB_NAME]=eventsand paste the header row in A1. - Freeze row 1; enable filter view.
- Data validation (optional but helpful):
actor_type: listuser,agent,human-reviewer.decision: listapproved,rejected,edited,n/a.pii_flagandreview_required: checkbox TRUE/FALSE.
- Format columns:
timestamp: Date time (GMT) display; keep raw values in RFC3339.latency_ms,input_tokens,output_tokens: Number, 0 decimals.cost_usd: Currency USD with 4–6 decimals.
- Create a “Config” tab with:
DAILY_CAP_USDin A2 →[50]BURN_7D_CAP_USDin A3 →[200]SLACK_WEBHOOK_URLin A4 →[https://hooks.slack.com/services/...](or leave blank until ready)SECRET_TOKENin A5 →[YOUR_LONG_RANDOM_TOKEN]
- Get the Sheet ID: it’s the long string in the URL. Save as
[SHEET_ID].
Apps Script webhook (POST → append row)
Paste this into Apps Script (Extensions → Apps Script). Replace [PLACEHOLDERS]. Deploy as Web App with “Anyone with the link” access.
const CONFIG = {
SHEET_ID: '[SHEET_ID]',
TAB_NAME: '[TAB_NAME]', // events
SECRET_TOKEN: '[SECRET_TOKEN]'
};
function doPost(e) {
try {
const body = e.postData && e.postData.contents ? JSON.parse(e.postData.contents) : {};
const token = e.parameter.token || body.token;
if (CONFIG.SECRET_TOKEN && token !== CONFIG.SECRET_TOKEN) {
return ContentService.createTextOutput(JSON.stringify({ ok:false, error:'unauthorized' })).setMimeType(ContentService.MimeType.JSON);
}
// Ensure all fields exist in header order
const row = [
body.event_id || Utilities.getUuid(),
body.timestamp || new Date().toISOString(),
body.actor_type || 'agent',
body.provider || 'unknown',
body.model || 'unknown',
body.region || 'n/a',
Number(body.input_tokens || 0),
Number(body.output_tokens || 0),
Number(body.latency_ms || 0),
Number(body.cost_usd || 0),
body.eval_score === undefined ? '' : Number(body.eval_score),
body.confidence === undefined ? '' : Number(body.confidence),
body.job_id || '',
body.customer_id || '',
body.pii_flag === true,
body.review_required === true,
body.reviewer_id || '',
body.decision || 'n/a',
body.error_code || ''
];
const ss = SpreadsheetApp.openById(CONFIG.SHEET_ID);
const sh = ss.getSheetByName(CONFIG.TAB_NAME);
const lock = LockService.getScriptLock();
lock.tryLock(5000);
sh.getRange(sh.getLastRow()+1, 1, 1, row.length).setValues([row]);
lock.releaseLock();
return ContentService.createTextOutput(JSON.stringify({ ok:true })).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ ok:false, error:String(err) })).setMimeType(ContentService.MimeType.JSON);
}
}
Notes:
- Quotas: Apps Script has execution and write limits; suitable up to a few requests/sec. If you outgrow it, move to the Parquet/DuckDB path below.
- Auth: Keep the token in the Config tab and in your server env (not in client code). Rotate if leaked.
Webhook payload example (cURL + Node)
Send events server-side after each LLM call. Example JSON payload (matches header order via field names):
{
"token": "[SECRET_TOKEN]",
"event_id": "[UUIDV4]",
"timestamp": "2026-05-01T18:04:23Z",
"actor_type": "agent",
"provider": "openai",
"model": "gpt-5.4-mini",
"region": "us-east-1",
"input_tokens": 1240,
"output_tokens": 318,
"latency_ms": 1420,
"cost_usd": 0.0021,
"eval_score": 0.78,
"confidence": 0.92,
"job_id": "job_8F3A",
"customer_id": "cus_12KD",
"pii_flag": false,
"review_required": false,
"reviewer_id": "",
"decision": "n/a",
"error_code": ""
}
cURL:
curl -X POST "[WEB_APP_URL]" \
-H "Content-Type: application/json" \
-d @payload.json
Node (fetch):
await fetch('[WEB_APP_URL]', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
Looker Studio: Weekly spend digest (Sheets connector)
Build a minimal weekly digest without standing up BI.
- Data source: Connect Looker Studio to your
[SHEET_URL](Google Sheets connector) and select theeventstab. - Recommended fields (ensure numeric types):
tokens_total=input_tokens + output_tokens$ per 1K tokens=cost_usd / (tokens_total/1000)(guard divide-by-zero)
- Starter report layout:
- Scorecards:
Today cost_usd(date filter = Today),Last 7 days cost_usd(Last 7 days),Last 30 days cost_usd(Last 30 days). - Time series: Daily
cost_usdover last 30 days. - Tables: Top
customer_idbycost_usd(Last 7 days); Topmodelbytokens_total. - Filter controls:
provider,actor_type,pii_flag.
- Scorecards:
- Delivery: Schedule a weekly email export (Monday 08:00 UTC) to
[TEAM_EMAILS].
Tip: For a true rolling 7‑day sum inside Looker Studio, set the report date range to “Last 7 days” and summarize by Day; for per‑customer rolling windows, use the DuckDB SQL below.
DuckDB + Parquet: queries to keep and reuse
Use Parquet for cheap, portable storage and DuckDB for local SQL. Good for >50k rows/day or when Sheets hits limits.
- Write events to Parquet (batch or stream). If you’re starting from CSV:
-- From a local CSV into Parquet
COPY (
SELECT * FROM read_csv_auto('events.csv')
) TO 'data/events_2026-05-01.parquet' (FORMAT 'parquet');
- Query all Parquet files in a folder or bucket:
-- Point at a folder (local or object storage mounted)
SELECT *
FROM read_parquet('data/events_*.parquet');
- Daily totals:
WITH base AS (
SELECT
CAST(date_trunc('day', to_timestamp(timestamp)) AS DATE) AS day,
cost_usd,
input_tokens,
output_tokens,
provider
FROM read_parquet('data/events_*.parquet')
)
SELECT
day,
provider,
SUM(cost_usd) AS cost_usd,
SUM(input_tokens) AS in_tokens,
SUM(output_tokens) AS out_tokens,
SUM(input_tokens+output_tokens) AS tokens_total
FROM base
GROUP BY 1,2
ORDER BY 1,2;
- Per‑customer totals (last 7 days):
WITH base AS (
SELECT
CAST(date_trunc('day', to_timestamp(timestamp)) AS DATE) AS day,
customer_id,
cost_usd
FROM read_parquet('data/events_*.parquet')
WHERE day >= current_date - INTERVAL 7 DAY
)
SELECT customer_id, SUM(cost_usd) AS cost_7d
FROM base
GROUP BY 1
ORDER BY cost_7d DESC;
- Rolling 7‑day burn per customer (full history):
WITH base AS (
SELECT
CAST(date_trunc('day', to_timestamp(timestamp)) AS DATE) AS day,
customer_id,
SUM(cost_usd) AS daily_cost
FROM read_parquet('data/events_*.parquet')
GROUP BY 1,2
)
SELECT
customer_id,
day,
daily_cost,
SUM(daily_cost) OVER (
PARTITION BY customer_id ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS burn_7d
FROM base
ORDER BY customer_id, day;
- Export aggregates for reporting/alerts:
COPY (
SELECT day, SUM(cost_usd) AS cost_usd
FROM (
SELECT CAST(date_trunc('day', to_timestamp(timestamp)) AS DATE) AS day, cost_usd
FROM read_parquet('data/events_*.parquet')
)
GROUP BY 1 ORDER BY 1
) TO 'out/daily_cost.csv' WITH (HEADER, DELIMITER ',');
Run via duckdb -c "<SQL>" in cron, or use a .duckdb database and a scheduled job.
Slack alert recipe (daily cap + 7‑day burn)
Trigger Slack when caps are crossed. You can compute today/7‑day spend in Sheets (pivot) or via the DuckDB queries above, then call the webhook.
Config placeholders:
[SLACK_WEBHOOK_URL][DAILY_CAP_USD][BURN_7D_CAP_USD]
Node example:
import fetch from 'node-fetch';
async function notifySlack({ todayCost, burn7d }) {
const url = process.env.SLACK_WEBHOOK_URL || '[SLACK_WEBHOOK_URL]';
const blocks = [
{ type: 'header', text: { type: 'plain_text', text: 'LLM Spend Alert' } },
{ type: 'section', text: { type: 'mrkdwn', text: `• Today: $${todayCost.toFixed(4)} (cap $[DAILY_CAP_USD])\n• Last 7 days: $${burn7d.toFixed(2)} (cap $[BURN_7D_CAP_USD])` } },
{ type: 'context', elements: [{ type: 'mrkdwn', text: 'Provider caps + degrade rules are active. See dashboard for details.' }]}
];
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blocks }) });
}
cURL payload (simple text):
curl -X POST -H 'Content-Type: application/json' \
-d '{"text":"LLM Spend Alert: Today=$[TODAY_COST] (cap $[DAILY_CAP_USD]); 7D=$[BURN_7D] (cap $[BURN_7D_CAP_USD])"}' \
[SLACK_WEBHOOK_URL]
Degraded‑mode routing (when caps are hit)
When caps hit, degrade instead of failing hard. Keep it simple and reversible.
Pseudocode (server-side guard):
const DAILY_CAP_USD = Number(process.env.DAILY_CAP_USD || '[DAILY_CAP_USD]');
const BURN_7D_CAP_USD = Number(process.env.BURN_7D_CAP_USD || '[BURN_7D_CAP_USD]');
async function chooseRoute(ctx) {
const { todayCost, burn7d } = await getSpendStats();
if (todayCost > DAILY_CAP_USD || burn7d > BURN_7D_CAP_USD) {
// Degraded mode
if (ctx.mustHaveAnswer === false) return { route: 'queue', until: '+2h' };
if (ctx.canUseCheaper) return { route: 'model', model: '[CHEAPER_MODEL_ID]' };
return { route: 'human_review', reviewer_id: '[REVIEWER_ID]' };
}
return { route: 'default', model: ctx.model };
}
Log the final route in decision and set review_required=true when handing to a human.
Naming conventions (provider/model/region/IDs)
Use short, predictable values so your SQL and dashboards stay portable.
- provider:
openai|anthropic|google|[other] - model: vendor identifier in lowercase, e.g.,
gpt-5.4-mini,claude-3.7-sonnet,gemini-2.2-flash. - region: cloud region or
globalif unknown, e.g.,us-east-1,europe-west4. - actor_type:
user|agent|human-reviewer. - event_id: UUIDv4 (server-generated if not present).
- timestamp: RFC3339 UTC (always store UTC).
- error_code: short codes like
429,RATE_LIMIT,TIMEOUT,PROVIDER_DOWN.
Folder layout (Parquet path suggestion):
/parquet/
provider=openai/yyyymmdd/events_*.parquet
provider=anthropic/yyyymmdd/events_*.parquet
Partitioning by provider/day keeps queries fast and cheap.
Retention and redaction (30/180-day defaults)
Default indie policy you can adopt and tune:
- Retention windows:
- Hot rows:
[30]days kept in Sheets for quick ops. - Warm archive:
[180]days in Parquet (object storage or disk).
- Hot rows:
- Redaction:
- Never store raw prompts/completions in this meter. If needed, hash (
SHA-256) and store only hashes. - Set
pii_flag=truewhen upstream detects PII; exclude such rows from public dashboards. - If you must keep text for review, store it in a separate secured bucket with short TTL and row-level ACL.
- Never store raw prompts/completions in this meter. If needed, hash (
- Access:
- Only the service account writes to Sheets; humans view via Looker Studio.
- Rotate
[SECRET_TOKEN]quarterly or on incident.
- Backups:
- Nightly export of the
eventstab to CSV → Parquet archive job.
- Nightly export of the
- Deletion:
- Hard-delete rows older than
[RETENTION_SHORT_DAYS]from Sheets; expire Parquet after[RETENTION_LONG_DAYS]using bucket lifecycle rules.
- Hard-delete rows older than
EU AI Act quick mapping (evidence + oversight)
Lightweight mapping to oversight/audit concepts (the Act is generally applicable on August 2, 2026):
- Evidence log:
event_id,timestamp,provider,model,latency_ms,error_codesupport traceability. - Human oversight:
review_required,reviewer_id, anddecisioncreate a clear reviewer trail. - Risk controls: daily cap + rolling 7‑day alerts show proportionate risk management; keep your alert history.
Add a short SOP: when decision='rejected', re-run in degraded mode or escalate to human with review_required=true.
Lightweight SaaS swap (PostHog + a billing meter)
If you prefer a hosted path:
- PostHog capture:
- Event name:
llm_request - Properties: map fields 1:1 (
provider,model,input_tokens,output_tokens,latency_ms,cost_usd,customer_id, etc.).
- Event name:
- Metering (e.g., Metronome):
- Define meters:
cost_usd(sum),tokens_total(sum), grouped bycustomer_id. - Alerts: daily and 7‑day thresholds; webhook to Slack or your ops endpoint.
- Define meters:
- Governance:
- Use project-level PII flags; avoid storing text; forward only counters/costs.
You can keep the CSV/Parquet schema identical so you can move between stacks without rewrites.
10‑minute smoke test (copy/paste checklist)
Prove it works in 10 minutes:
- Post two sample events (copy the payload example, tweak
cost_usdto0.05and3.10). - In Sheets, confirm both rows append and numeric columns cast correctly.
- In Looker Studio, build a quick scorecard for
Last 7 days cost_usdand confirm it matches your test total. - Run the DuckDB
daily totalsquery against a Parquet export and verify the same total. - Temporarily set
[DAILY_CAP_USD]=0.01and run the Slack notifier with today’s sum to trigger an alert. Reset after the test.