@hasna/economy 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +73 -60
  3. package/dashboard/README.md +17 -64
  4. package/dist/cli/commands/extras.d.ts.map +1 -1
  5. package/dist/cli/commands/todos.d.ts +3 -0
  6. package/dist/cli/commands/todos.d.ts.map +1 -1
  7. package/dist/cli/index.js +914 -199
  8. package/dist/db/cloud.d.ts +3 -4
  9. package/dist/db/cloud.d.ts.map +1 -1
  10. package/dist/db/database.d.ts +10 -3
  11. package/dist/db/database.d.ts.map +1 -1
  12. package/dist/db/dialect.d.ts +13 -0
  13. package/dist/db/dialect.d.ts.map +1 -0
  14. package/dist/db/pg-migrate.d.ts +30 -0
  15. package/dist/db/pg-migrate.d.ts.map +1 -0
  16. package/dist/db/pg-migrations.d.ts.map +1 -1
  17. package/dist/db/sqlite-adapter.d.ts +36 -0
  18. package/dist/db/sqlite-adapter.d.ts.map +1 -0
  19. package/dist/db/sync-pg.d.ts +1 -1
  20. package/dist/db/sync-pg.d.ts.map +1 -1
  21. package/dist/index.js +309 -59
  22. package/dist/ingest/billing.d.ts +1 -1
  23. package/dist/ingest/billing.d.ts.map +1 -1
  24. package/dist/ingest/claude-quota.d.ts +1 -1
  25. package/dist/ingest/claude-quota.d.ts.map +1 -1
  26. package/dist/ingest/claude.d.ts +1 -1
  27. package/dist/ingest/claude.d.ts.map +1 -1
  28. package/dist/ingest/codex-quota.d.ts +1 -1
  29. package/dist/ingest/codex-quota.d.ts.map +1 -1
  30. package/dist/ingest/codex.d.ts +1 -1
  31. package/dist/ingest/codex.d.ts.map +1 -1
  32. package/dist/ingest/cursor.d.ts +1 -1
  33. package/dist/ingest/cursor.d.ts.map +1 -1
  34. package/dist/ingest/gemini.d.ts +1 -1
  35. package/dist/ingest/gemini.d.ts.map +1 -1
  36. package/dist/ingest/hermes.d.ts +1 -1
  37. package/dist/ingest/hermes.d.ts.map +1 -1
  38. package/dist/ingest/loops.d.ts +6 -0
  39. package/dist/ingest/loops.d.ts.map +1 -0
  40. package/dist/ingest/opencode.d.ts +1 -1
  41. package/dist/ingest/opencode.d.ts.map +1 -1
  42. package/dist/ingest/otel.d.ts +16 -3
  43. package/dist/ingest/otel.d.ts.map +1 -1
  44. package/dist/ingest/pi.d.ts +1 -1
  45. package/dist/ingest/pi.d.ts.map +1 -1
  46. package/dist/ingest/plugin.d.ts +1 -1
  47. package/dist/ingest/plugin.d.ts.map +1 -1
  48. package/dist/lib/accounts.d.ts.map +1 -1
  49. package/dist/lib/analytics.d.ts +1 -1
  50. package/dist/lib/analytics.d.ts.map +1 -1
  51. package/dist/lib/billing-diff.d.ts +1 -1
  52. package/dist/lib/billing-diff.d.ts.map +1 -1
  53. package/dist/lib/brief.d.ts +1 -1
  54. package/dist/lib/brief.d.ts.map +1 -1
  55. package/dist/lib/open-projects.d.ts +10 -2
  56. package/dist/lib/open-projects.d.ts.map +1 -1
  57. package/dist/lib/pricing.d.ts +1 -1
  58. package/dist/lib/pricing.d.ts.map +1 -1
  59. package/dist/lib/savings.d.ts +1 -1
  60. package/dist/lib/savings.d.ts.map +1 -1
  61. package/dist/lib/spikes.d.ts +1 -1
  62. package/dist/lib/spikes.d.ts.map +1 -1
  63. package/dist/lib/store/index.d.ts +9 -1
  64. package/dist/lib/store/index.d.ts.map +1 -1
  65. package/dist/lib/sync-all.d.ts +3 -1
  66. package/dist/lib/sync-all.d.ts.map +1 -1
  67. package/dist/lib/webhooks.d.ts +1 -1
  68. package/dist/lib/webhooks.d.ts.map +1 -1
  69. package/dist/mcp/index.js +757 -166
  70. package/dist/mcp/server.d.ts.map +1 -1
  71. package/dist/openapi.d.ts.map +1 -1
  72. package/dist/otel/index.js +262 -27
  73. package/dist/server/index.js +683 -135
  74. package/dist/server/serve.d.ts +1 -1
  75. package/dist/server/serve.d.ts.map +1 -1
  76. package/dist/types/index.d.ts +37 -4
  77. package/dist/types/index.d.ts.map +1 -1
  78. package/docs/README.md +12 -0
  79. package/docs/cli.md +88 -0
  80. package/docs/configuration.md +86 -0
  81. package/docs/ingestion.md +47 -0
  82. package/docs/mcp.md +65 -0
  83. package/docs/otel.md +53 -0
  84. package/docs/rest-api.md +89 -0
  85. package/package.json +4 -5
  86. package/dist/lib/cloud-sync.d.ts +0 -35
  87. package/dist/lib/cloud-sync.d.ts.map +0 -1
  88. package/dist/lib/fleet-sync.d.ts +0 -61
  89. package/dist/lib/fleet-sync.d.ts.map +0 -1
  90. package/dist/lib/peer-sync.d.ts +0 -21
  91. package/dist/lib/peer-sync.d.ts.map +0 -1
  92. package/dist/storage.js +0 -2746
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAaA,eAAO,MAAM,QAAQ,YAAY,CAAA;AACjC,eAAO,MAAM,qBAAqB,OAAO,CAAA;AAEzC,wBAAgB,WAAW,IAAI,GAAG,CAqpBjC"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAYA,eAAO,MAAM,QAAQ,YAAY,CAAA;AACjC,eAAO,MAAM,qBAAqB,OAAO,CAAA;AAEzC,wBAAgB,WAAW,IAAI,GAAG,CAm0BjC"}
@@ -1 +1 @@
1
- {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAwjD/C,CAAA;AACD,eAAe,WAAW,CAAA"}
1
+ {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAikD/C,CAAA;AACD,eAAe,WAAW,CAAA"}
@@ -16,6 +16,62 @@ var __export = (target, all) => {
16
16
  };
17
17
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
 
19
+ // src/db/sqlite-adapter.ts
20
+ import { Database } from "bun:sqlite";
21
+
22
+ class SqliteAdapter {
23
+ db;
24
+ constructor(path) {
25
+ this.db = new Database(path, { create: true });
26
+ this.db.exec("PRAGMA journal_mode=WAL");
27
+ this.db.exec("PRAGMA foreign_keys=ON");
28
+ }
29
+ run(sql, ...params) {
30
+ const result = this.db.prepare(sql).run(...params);
31
+ return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
32
+ }
33
+ get(sql, ...params) {
34
+ return this.db.prepare(sql).get(...params);
35
+ }
36
+ all(sql, ...params) {
37
+ return this.db.prepare(sql).all(...params);
38
+ }
39
+ exec(sql) {
40
+ this.db.exec(sql);
41
+ }
42
+ query(sql) {
43
+ return this.db.query(sql);
44
+ }
45
+ prepare(sql) {
46
+ const stmt = this.db.prepare(sql);
47
+ return {
48
+ run(...params) {
49
+ const r = stmt.run(...params);
50
+ return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };
51
+ },
52
+ get(...params) {
53
+ return stmt.get(...params);
54
+ },
55
+ all(...params) {
56
+ return stmt.all(...params);
57
+ },
58
+ finalize() {
59
+ stmt.finalize();
60
+ }
61
+ };
62
+ }
63
+ close() {
64
+ this.db.close();
65
+ }
66
+ transaction(fn) {
67
+ return this.db.transaction(fn)();
68
+ }
69
+ get raw() {
70
+ return this.db;
71
+ }
72
+ }
73
+ var init_sqlite_adapter = () => {};
74
+
19
75
  // src/lib/pricing.ts
20
76
  var exports_pricing = {};
21
77
  __export(exports_pricing, {
@@ -534,7 +590,6 @@ var init_pricing = __esm(() => {
534
590
  });
535
591
 
536
592
  // src/db/database.ts
537
- import { SqliteAdapter as Database } from "@hasna/cloud";
538
593
  import { execFileSync } from "child_process";
539
594
  import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
540
595
  import { hostname, platform } from "os";
@@ -627,7 +682,7 @@ function openDatabase(dbPath, skipSeed = false) {
627
682
  mkdirSync(dir, { recursive: true });
628
683
  }
629
684
  const db = withSqliteBusyRetry(() => {
630
- const opened = new Database(path);
685
+ const opened = new SqliteAdapter(path);
631
686
  try {
632
687
  opened.exec("PRAGMA busy_timeout = 10000");
633
688
  opened.exec("PRAGMA journal_mode = WAL");
@@ -668,6 +723,16 @@ function addColumnIfMissing(db, table, column, definition) {
668
723
  }
669
724
  function initSchema(db) {
670
725
  db.exec(`
726
+ CREATE TABLE IF NOT EXISTS cost_centers (
727
+ id TEXT PRIMARY KEY,
728
+ kind TEXT NOT NULL,
729
+ name TEXT NOT NULL,
730
+ repo_path TEXT,
731
+ labels_json TEXT DEFAULT '{}',
732
+ created_at TEXT NOT NULL,
733
+ updated_at TEXT NOT NULL
734
+ );
735
+
671
736
  CREATE TABLE IF NOT EXISTS requests (
672
737
  id TEXT PRIMARY KEY,
673
738
  agent TEXT NOT NULL,
@@ -684,6 +749,7 @@ function initSchema(db) {
684
749
  timestamp TEXT NOT NULL,
685
750
  source_request_id TEXT,
686
751
  machine_id TEXT DEFAULT '',
752
+ cost_center_id TEXT,
687
753
  account_key TEXT DEFAULT '',
688
754
  account_tool TEXT DEFAULT '',
689
755
  account_name TEXT DEFAULT '',
@@ -702,6 +768,7 @@ function initSchema(db) {
702
768
  total_tokens INTEGER DEFAULT 0,
703
769
  request_count INTEGER DEFAULT 0,
704
770
  machine_id TEXT DEFAULT '',
771
+ cost_center_id TEXT,
705
772
  account_key TEXT DEFAULT '',
706
773
  account_tool TEXT DEFAULT '',
707
774
  account_name TEXT DEFAULT '',
@@ -722,6 +789,7 @@ function initSchema(db) {
722
789
  id TEXT PRIMARY KEY,
723
790
  project_path TEXT,
724
791
  agent TEXT,
792
+ cost_center_id TEXT,
725
793
  period TEXT NOT NULL,
726
794
  limit_usd REAL NOT NULL,
727
795
  alert_at_percent INTEGER DEFAULT 80,
@@ -752,6 +820,7 @@ function initSchema(db) {
752
820
  CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
753
821
  CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
754
822
  CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
823
+ CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind);
755
824
 
756
825
  CREATE TABLE IF NOT EXISTS model_pricing (
757
826
  model TEXT PRIMARY KEY,
@@ -838,6 +907,12 @@ function initSchema(db) {
838
907
  `);
839
908
  addColumnIfMissing(db, "requests", "machine_id", `TEXT DEFAULT ''`);
840
909
  addColumnIfMissing(db, "sessions", "machine_id", `TEXT DEFAULT ''`);
910
+ if (addColumnIfMissing(db, "cost_centers", "updated_at", `TEXT DEFAULT ''`)) {
911
+ db.exec(`UPDATE cost_centers SET updated_at = created_at WHERE updated_at = '' OR updated_at IS NULL`);
912
+ }
913
+ addColumnIfMissing(db, "requests", "cost_center_id", `TEXT`);
914
+ addColumnIfMissing(db, "sessions", "cost_center_id", `TEXT`);
915
+ addColumnIfMissing(db, "budgets", "cost_center_id", `TEXT`);
841
916
  if (addColumnIfMissing(db, "requests", "cache_create_5m_tokens", "INTEGER DEFAULT 0")) {
842
917
  db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
843
918
  }
@@ -866,8 +941,20 @@ function initSchema(db) {
866
941
  CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
867
942
  CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key);
868
943
  CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key);
944
+ CREATE INDEX IF NOT EXISTS idx_requests_cost_center ON requests(cost_center_id);
945
+ CREATE INDEX IF NOT EXISTS idx_sessions_cost_center ON sessions(cost_center_id);
946
+ CREATE INDEX IF NOT EXISTS idx_budgets_cost_center ON budgets(cost_center_id);
947
+ CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind);
869
948
  `);
870
949
  }
950
+ function upsertCostCenter(db, center) {
951
+ const updatedAt = center.updated_at ?? center.created_at;
952
+ db.prepare(`
953
+ INSERT OR REPLACE INTO cost_centers
954
+ (id, kind, name, repo_path, labels_json, created_at, updated_at)
955
+ VALUES (?, ?, ?, ?, ?, ?, ?)
956
+ `).run(center.id, center.kind, center.name, center.repo_path ?? null, center.labels_json ?? "{}", center.created_at, updatedAt);
957
+ }
871
958
  function upsertRequest(db, req) {
872
959
  const now = req.updated_at ?? new Date().toISOString();
873
960
  db.prepare(`
@@ -875,10 +962,10 @@ function upsertRequest(db, req) {
875
962
  (id, agent, session_id, model, input_tokens, output_tokens,
876
963
  cache_read_tokens, cache_create_tokens, cache_create_5m_tokens,
877
964
  cache_create_1h_tokens, cost_usd, cost_basis, duration_ms, timestamp,
878
- source_request_id, machine_id, attribution_tag, account_key, account_tool,
965
+ source_request_id, machine_id, cost_center_id, attribution_tag, account_key, account_tool,
879
966
  account_name, account_email, account_source, updated_at, synced_at)
880
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
881
- `).run(req.id, req.agent, req.session_id, req.model, req.input_tokens, req.output_tokens, req.cache_read_tokens, req.cache_create_tokens, req.cache_create_5m_tokens ?? req.cache_create_tokens, req.cache_create_1h_tokens ?? 0, req.cost_usd, req.cost_basis ?? "estimated", req.duration_ms, req.timestamp, req.source_request_id, req.machine_id ?? "", req.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", req.account_key ?? "", req.account_tool ?? "", req.account_name ?? "", req.account_email ?? "", req.account_source ?? "", now, req.synced_at ?? "");
967
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
968
+ `).run(req.id, req.agent, req.session_id, req.model, req.input_tokens, req.output_tokens, req.cache_read_tokens, req.cache_create_tokens, req.cache_create_5m_tokens ?? req.cache_create_tokens, req.cache_create_1h_tokens ?? 0, req.cost_usd, req.cost_basis ?? "estimated", req.duration_ms, req.timestamp, req.source_request_id, req.machine_id ?? "", req.cost_center_id ?? null, req.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", req.account_key ?? "", req.account_tool ?? "", req.account_name ?? "", req.account_email ?? "", req.account_source ?? "", now, req.synced_at ?? "");
882
969
  }
883
970
  function upsertSession(db, session) {
884
971
  const now = session.updated_at ?? new Date().toISOString();
@@ -886,9 +973,9 @@ function upsertSession(db, session) {
886
973
  INSERT OR REPLACE INTO sessions
887
974
  (id, agent, project_path, project_name, started_at, ended_at,
888
975
  total_cost_usd, total_tokens, request_count, machine_id, attribution_tag,
889
- account_key, account_tool, account_name, account_email, account_source, updated_at, synced_at)
890
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
891
- `).run(session.id, session.agent, session.project_path, session.project_name, session.started_at, session.ended_at ?? null, session.total_cost_usd, session.total_tokens, session.request_count, session.machine_id ?? "", session.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", session.account_key ?? "", session.account_tool ?? "", session.account_name ?? "", session.account_email ?? "", session.account_source ?? "", now, session.synced_at ?? "");
976
+ cost_center_id, account_key, account_tool, account_name, account_email, account_source, updated_at, synced_at)
977
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
978
+ `).run(session.id, session.agent, session.project_path, session.project_name, session.started_at, session.ended_at ?? null, session.total_cost_usd, session.total_tokens, session.request_count, session.machine_id ?? "", session.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", session.cost_center_id ?? null, session.account_key ?? "", session.account_tool ?? "", session.account_name ?? "", session.account_email ?? "", session.account_source ?? "", now, session.synced_at ?? "");
892
979
  }
893
980
  function rollupSession(db, sessionId) {
894
981
  db.prepare(`
@@ -914,9 +1001,12 @@ function rollupSession(db, sessionId) {
914
1001
  ELSE account_email END,
915
1002
  account_source = CASE WHEN account_source = '' OR account_source IS NULL
916
1003
  THEN COALESCE((SELECT account_source FROM requests WHERE session_id = ? AND account_source != '' ORDER BY timestamp DESC LIMIT 1), '')
917
- ELSE account_source END
1004
+ ELSE account_source END,
1005
+ cost_center_id = CASE WHEN cost_center_id = '' OR cost_center_id IS NULL
1006
+ THEN COALESCE((SELECT cost_center_id FROM requests WHERE session_id = ? AND cost_center_id IS NOT NULL AND cost_center_id != '' ORDER BY timestamp DESC LIMIT 1), NULL)
1007
+ ELSE cost_center_id END
918
1008
  WHERE id = ?
919
- `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
1009
+ `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
920
1010
  }
921
1011
  function upsertModelPricing(db, p) {
922
1012
  db.prepare(`
@@ -949,7 +1039,9 @@ function seedModelPricing(db, defaults) {
949
1039
  });
950
1040
  }
951
1041
  }
952
- var init_database = () => {};
1042
+ var init_database = __esm(() => {
1043
+ init_sqlite_adapter();
1044
+ });
953
1045
 
954
1046
  // src/otel/index.ts
955
1047
  init_database();
@@ -995,6 +1087,97 @@ function attrsMap(attributes) {
995
1087
  }
996
1088
  return out;
997
1089
  }
1090
+ var COST_CENTER_KINDS = ["loop", "app", "repo", "service", "team"];
1091
+ var PSEUDO_AGENTS = ["app", "service", "repo", "loop"];
1092
+ function isCostCenterKind(value) {
1093
+ return COST_CENTER_KINDS.includes(value);
1094
+ }
1095
+ function asString(value) {
1096
+ if (value == null)
1097
+ return;
1098
+ const text = String(value).trim();
1099
+ return text ? text : undefined;
1100
+ }
1101
+ function firstString(source, keys) {
1102
+ for (const key of keys) {
1103
+ const value = asString(source[key]);
1104
+ if (value)
1105
+ return value;
1106
+ }
1107
+ return;
1108
+ }
1109
+ function normalizeCostCenterKind(raw) {
1110
+ if (!raw)
1111
+ return;
1112
+ const value = raw.toLowerCase();
1113
+ return isCostCenterKind(value) ? value : undefined;
1114
+ }
1115
+ function normalizeAgent(raw, kind) {
1116
+ if (raw && isAgent(raw))
1117
+ return raw;
1118
+ if (raw && PSEUDO_AGENTS.includes(raw))
1119
+ return raw;
1120
+ if (kind && PSEUDO_AGENTS.includes(kind))
1121
+ return kind;
1122
+ return "service";
1123
+ }
1124
+ function slug(value) {
1125
+ return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
1126
+ }
1127
+ function costCenterId(kind, name, explicitId) {
1128
+ if (explicitId)
1129
+ return explicitId;
1130
+ if (!kind || !name)
1131
+ return;
1132
+ return `${kind}:${slug(name)}`;
1133
+ }
1134
+ function projectNameFromPath(projectPath) {
1135
+ if (!projectPath)
1136
+ return;
1137
+ return projectPath.split("/").filter(Boolean).at(-1);
1138
+ }
1139
+ function extractAttribution(source) {
1140
+ const sourceKind = firstString(source, ["source", "source_kind", "ai.source"]);
1141
+ const rawKind = firstString(source, ["cost_center_kind", "cost_center.kind", "economy.cost_center.kind"]) ?? sourceKind;
1142
+ const costCenterKind = normalizeCostCenterKind(rawKind);
1143
+ const costCenter = firstString(source, [
1144
+ "cost_center",
1145
+ "cost_center_name",
1146
+ "cost_center.name",
1147
+ "economy.cost_center",
1148
+ "service.name",
1149
+ "app",
1150
+ "app.name",
1151
+ "repo",
1152
+ "repository"
1153
+ ]);
1154
+ const projectPath = firstString(source, ["project_path", "project.path", "repo_path", "repository.path"]);
1155
+ const explicitId = firstString(source, ["cost_center_id", "cost_center.id", "economy.cost_center.id"]);
1156
+ const costBasisRaw = firstString(source, ["cost_basis", "economy.cost_basis"]);
1157
+ const costBasis = costBasisRaw && ["metered_api", "subscription_included", "estimated", "unknown"].includes(costBasisRaw) ? costBasisRaw : undefined;
1158
+ return {
1159
+ agent: normalizeAgent(firstString(source, ["agent", "ai.agent"]) ?? sourceKind, costCenterKind),
1160
+ cost_center: costCenter,
1161
+ cost_center_kind: costCenterKind,
1162
+ cost_center_id: costCenterId(costCenterKind, costCenter, explicitId),
1163
+ attribution_tag: firstString(source, ["attribution_tag", "attribution.tag", "economy.attribution_tag"]),
1164
+ project_path: projectPath,
1165
+ project_name: firstString(source, ["project_name", "project.name"]) ?? projectNameFromPath(projectPath) ?? costCenter,
1166
+ repo: firstString(source, ["repo", "repository", "repository.name"]),
1167
+ account_key: firstString(source, ["account_key", "account.key", "economy.account_key"]),
1168
+ account_tool: firstString(source, ["account_tool", "account.tool", "economy.account_tool"]),
1169
+ account_name: firstString(source, ["account_name", "account.name", "economy.account_name"]),
1170
+ account_email: firstString(source, ["account_email", "account.email", "economy.account_email"]),
1171
+ account_source: firstString(source, ["account_source", "account.source", "economy.account_source"]),
1172
+ cost_basis: costBasis
1173
+ };
1174
+ }
1175
+ function mergeAttribution(row, attribution) {
1176
+ return {
1177
+ ...row,
1178
+ ...Object.fromEntries(Object.entries(attribution).filter(([, value]) => value !== undefined && value !== ""))
1179
+ };
1180
+ }
998
1181
  function metricKind(body) {
999
1182
  const name = String(body["name"] ?? "").toLowerCase();
1000
1183
  if (name.includes("cost") || name.includes(".usd"))
@@ -1043,13 +1226,13 @@ function parseOtlpMetrics(body) {
1043
1226
  continue;
1044
1227
  const pointAttrs = attrsMap(dp.attributes);
1045
1228
  const merged = { ...resourceAttrs, ...pointAttrs };
1046
- const agentRaw = String(merged["agent"] ?? merged["ai.agent"] ?? "unknown");
1047
- const agent = isAgent(agentRaw) ? agentRaw : "opencode";
1229
+ const attribution = extractAttribution(merged);
1230
+ const agent = attribution.agent;
1048
1231
  const sessionId = String(merged["session_id"] ?? merged["session.id"] ?? "otel-session");
1049
1232
  const model = String(merged["model"] ?? merged["ai.model"] ?? "unknown");
1050
1233
  const sourceId = String(merged["request_id"] ?? merged["event.id"] ?? `${sessionId}-${kind}`);
1051
1234
  const key = `${agent}:${sourceId}`;
1052
- const row = partial.get(key) ?? {
1235
+ const row = mergeAttribution(partial.get(key) ?? {
1053
1236
  key,
1054
1237
  agent,
1055
1238
  session_id: sessionId,
@@ -1059,7 +1242,7 @@ function parseOtlpMetrics(body) {
1059
1242
  output_tokens: 0,
1060
1243
  timestamp: new Date().toISOString(),
1061
1244
  source_request_id: sourceId
1062
- };
1245
+ }, attribution);
1063
1246
  const asDouble = dp.asDouble;
1064
1247
  const asInt = dp.asInt;
1065
1248
  const value = asDouble ?? (asInt != null ? Number(asInt) : 0);
@@ -1086,16 +1269,27 @@ function parseOtlpMetrics(body) {
1086
1269
  input_tokens: r.input_tokens ?? 0,
1087
1270
  output_tokens: r.output_tokens ?? 0,
1088
1271
  timestamp: r.timestamp,
1089
- source_request_id: r.source_request_id
1272
+ source_request_id: r.source_request_id,
1273
+ cost_basis: r.cost_basis,
1274
+ cost_center: r.cost_center,
1275
+ cost_center_kind: r.cost_center_kind,
1276
+ cost_center_id: r.cost_center_id,
1277
+ attribution_tag: r.attribution_tag,
1278
+ project_path: r.project_path,
1279
+ project_name: r.project_name,
1280
+ repo: r.repo,
1281
+ account_key: r.account_key,
1282
+ account_tool: r.account_tool,
1283
+ account_name: r.account_name,
1284
+ account_email: r.account_email,
1285
+ account_source: r.account_source
1090
1286
  }));
1091
1287
  }
1092
1288
  function parseSimpleIngest(body) {
1093
1289
  if (!body || typeof body !== "object")
1094
1290
  return null;
1095
1291
  const b = body;
1096
- const agentRaw = String(b["agent"] ?? "");
1097
- if (!isAgent(agentRaw))
1098
- return null;
1292
+ const attribution = extractAttribution(b);
1099
1293
  const cost = Number(b["cost_usd"] ?? 0);
1100
1294
  const input = Number(b["input_tokens"] ?? 0);
1101
1295
  const output = Number(b["output_tokens"] ?? 0);
@@ -1104,7 +1298,8 @@ function parseSimpleIngest(body) {
1104
1298
  const sessionId = String(b["session_id"] ?? "otel-session");
1105
1299
  const sourceId = String(b["request_id"] ?? b["source_request_id"] ?? `${sessionId}-${Date.now()}`);
1106
1300
  return {
1107
- agent: agentRaw,
1301
+ ...attribution,
1302
+ agent: attribution.agent,
1108
1303
  session_id: sessionId,
1109
1304
  model: String(b["model"] ?? "unknown"),
1110
1305
  cost_usd: cost,
@@ -1114,12 +1309,31 @@ function parseSimpleIngest(body) {
1114
1309
  source_request_id: sourceId
1115
1310
  };
1116
1311
  }
1312
+ function ensureCostCenter(db, row) {
1313
+ const id = row.cost_center_id ?? costCenterId(row.cost_center_kind, row.cost_center);
1314
+ if (!id || !row.cost_center_kind || !row.cost_center)
1315
+ return id;
1316
+ const labels = { source: "otel" };
1317
+ if (row.repo)
1318
+ labels["repo"] = row.repo;
1319
+ upsertCostCenter(db, {
1320
+ id,
1321
+ kind: row.cost_center_kind,
1322
+ name: row.cost_center,
1323
+ repo_path: row.project_path ?? null,
1324
+ labels_json: JSON.stringify(labels),
1325
+ created_at: row.timestamp
1326
+ });
1327
+ return id;
1328
+ }
1117
1329
  async function ingestOtelRows(db, rows) {
1118
1330
  const machineId = getMachineId();
1119
1331
  const sessions = new Set;
1120
1332
  let requests = 0;
1121
1333
  for (const row of rows) {
1122
- const reqId = `otel-${row.agent}-${row.source_request_id}`;
1334
+ const costCenterId2 = ensureCostCenter(db, row);
1335
+ const requestScope = slug(costCenterId2 ?? row.cost_center ?? row.project_path ?? row.session_id);
1336
+ const reqId = `otel-${row.agent}-${requestScope}-${row.source_request_id}`;
1123
1337
  upsertRequest(db, {
1124
1338
  id: reqId,
1125
1339
  agent: row.agent,
@@ -1130,25 +1344,38 @@ async function ingestOtelRows(db, rows) {
1130
1344
  cache_read_tokens: 0,
1131
1345
  cache_create_tokens: 0,
1132
1346
  cost_usd: row.cost_usd,
1133
- cost_basis: "estimated",
1347
+ cost_basis: row.cost_basis ?? "estimated",
1134
1348
  duration_ms: 0,
1135
1349
  timestamp: row.timestamp,
1136
1350
  source_request_id: row.source_request_id,
1137
1351
  machine_id: machineId,
1138
- attribution_tag: "otel"
1352
+ cost_center_id: costCenterId2 ?? null,
1353
+ attribution_tag: row.attribution_tag ?? "otel",
1354
+ account_key: row.account_key ?? "",
1355
+ account_tool: row.account_tool ?? "",
1356
+ account_name: row.account_name ?? "",
1357
+ account_email: row.account_email ?? "",
1358
+ account_source: row.account_source ?? ""
1139
1359
  });
1140
1360
  if (!sessions.has(row.session_id)) {
1141
1361
  upsertSession(db, {
1142
1362
  id: row.session_id,
1143
1363
  agent: row.agent,
1144
- project_path: "",
1145
- project_name: "",
1364
+ project_path: row.project_path ?? "",
1365
+ project_name: row.project_name ?? row.repo ?? row.cost_center ?? "",
1146
1366
  started_at: row.timestamp,
1147
1367
  ended_at: null,
1148
1368
  total_cost_usd: 0,
1149
1369
  total_tokens: 0,
1150
1370
  request_count: 0,
1151
- machine_id: machineId
1371
+ machine_id: machineId,
1372
+ cost_center_id: costCenterId2 ?? null,
1373
+ attribution_tag: row.attribution_tag ?? "otel",
1374
+ account_key: row.account_key ?? "",
1375
+ account_tool: row.account_tool ?? "",
1376
+ account_name: row.account_name ?? "",
1377
+ account_email: row.account_email ?? "",
1378
+ account_source: row.account_source ?? ""
1152
1379
  });
1153
1380
  sessions.add(row.session_id);
1154
1381
  }
@@ -1186,10 +1413,18 @@ function resolvePort(argv) {
1186
1413
  }
1187
1414
  var args = process.argv.slice(2);
1188
1415
  if (args.includes("--help") || args.includes("-h")) {
1189
- console.log(`Usage: economy-otel [--port 4318]
1416
+ console.log(`Usage: economy-otel [options]
1190
1417
 
1191
1418
  OTLP/HTTP metrics sidecar \u2014 ingests *.cost.* / *.token.* metrics into economy.db
1192
1419
 
1420
+ Options:
1421
+ -p, --port <port> Port to bind (default: ECONOMY_OTEL_PORT or 4318)
1422
+ -h, --help display help for command
1423
+
1424
+ Environment:
1425
+ ECONOMY_OTEL_PORT Override the default port (4318)
1426
+ ECONOMY_OTEL_BIND Bind address (default: 127.0.0.1)
1427
+
1193
1428
  Endpoints:
1194
1429
  POST /v1/metrics OTLP JSON metrics
1195
1430
  POST /ingest Simplified single-event JSON