agentmetrics-server 0.1.0__py3-none-any.whl

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 (63) hide show
  1. agentmetrics_server-0.1.0.dist-info/METADATA +147 -0
  2. agentmetrics_server-0.1.0.dist-info/RECORD +63 -0
  3. agentmetrics_server-0.1.0.dist-info/WHEEL +4 -0
  4. agentmetrics_server-0.1.0.dist-info/entry_points.txt +2 -0
  5. alembic/env.py +45 -0
  6. alembic/script.py.mako +25 -0
  7. alembic/versions/001_initial_schema.py +64 -0
  8. alembic/versions/002_placeholder.py +20 -0
  9. alembic/versions/003_password_reset.py +41 -0
  10. alembic/versions/004_data_integrity.py +34 -0
  11. alembic/versions/005_agents_metadata.py +65 -0
  12. alembic/versions/006_steps_table.py +59 -0
  13. alembic/versions/007_metrics_aggregation.py +80 -0
  14. alembic/versions/008_recommendations_alerts.py +100 -0
  15. alembic/versions/009_org_settings.py +27 -0
  16. alembic/versions/010_schema_fixes.py +70 -0
  17. alembic/versions/011_perf_indexes.py +26 -0
  18. alembic/versions/012_v2_schema_promotion.py +68 -0
  19. alembic/versions/013_remove_supabase_add_password.py +45 -0
  20. alembic/versions/014_schema_hardening.py +43 -0
  21. alembic/versions/015_api_key_auth.py +27 -0
  22. alembic.ini +38 -0
  23. app/__init__.py +0 -0
  24. app/config.py +26 -0
  25. app/core/__init__.py +0 -0
  26. app/core/activity_store.py +59 -0
  27. app/core/pricing.py +191 -0
  28. app/core/rate_limit.py +49 -0
  29. app/database.py +29 -0
  30. app/db_compat.py +73 -0
  31. app/deps.py +63 -0
  32. app/hooks.py +99 -0
  33. app/main.py +299 -0
  34. app/models/__init__.py +5 -0
  35. app/models/event.py +57 -0
  36. app/models/metrics.py +73 -0
  37. app/models/organization.py +20 -0
  38. app/routers/__init__.py +0 -0
  39. app/routers/activity.py +122 -0
  40. app/routers/agents.py +141 -0
  41. app/routers/alerts.py +163 -0
  42. app/routers/audit.py +149 -0
  43. app/routers/auth.py +84 -0
  44. app/routers/events.py +165 -0
  45. app/routers/fleet.py +287 -0
  46. app/routers/recommendations.py +66 -0
  47. app/routers/runs.py +100 -0
  48. app/routers/slo.py +150 -0
  49. app/routers/stats.py +283 -0
  50. app/schemas/__init__.py +0 -0
  51. app/schemas/activity.py +30 -0
  52. app/schemas/agent.py +140 -0
  53. app/schemas/auth.py +20 -0
  54. app/schemas/event.py +95 -0
  55. app/services/__init__.py +0 -0
  56. app/services/agent_service.py +465 -0
  57. app/services/aggregation_service.py +351 -0
  58. app/services/alert_service.py +343 -0
  59. app/services/email_service.py +184 -0
  60. app/services/recommendation_service.py +261 -0
  61. app/worker.py +164 -0
  62. cli.py +144 -0
  63. daemon.py +377 -0
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentmetrics-server
3
+ Version: 0.1.0
4
+ Summary: AgentMetrics - self-hosted AI agent observability server
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: alembic>=1.13.1
8
+ Requires-Dist: apscheduler==3.10.4
9
+ Requires-Dist: email-validator==2.1.1
10
+ Requires-Dist: fastapi>=0.115.0
11
+ Requires-Dist: httpx==0.27.0
12
+ Requires-Dist: passlib[bcrypt]==1.7.4
13
+ Requires-Dist: pydantic-settings>=2.6.0
14
+ Requires-Dist: pydantic>=2.10.0
15
+ Requires-Dist: python-jose[cryptography]==3.3.0
16
+ Requires-Dist: python-multipart==0.0.9
17
+ Requires-Dist: requests==2.32.3
18
+ Requires-Dist: sqlalchemy>=2.0.30
19
+ Requires-Dist: uvicorn[standard]>=0.29.0
20
+ Provides-Extra: postgres
21
+ Requires-Dist: psycopg2-binary>=2.9.9; extra == 'postgres'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # agentmetrics-server
25
+
26
+ [![PyPI](https://img.shields.io/pypi/v/agentmetrics-server?color=6366f1&label=pypi&logo=python&logoColor=white)](https://pypi.org/project/agentmetrics-server)
27
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776AB?logo=python&logoColor=white)](https://pypi.org/project/agentmetrics-server)
28
+ [![License: MIT](https://img.shields.io/badge/license-MIT-6366f1)](../LICENSE)
29
+
30
+ Self-hosted AgentMetrics API server. Install it once and every agent instrumented with the AgentMetrics SDK reports to your own dashboard showing cost, latency, token usage, tool calls, and failures, with no cloud account and no data leaving your infrastructure.
31
+
32
+ ---
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install agentmetrics-server
38
+ ```
39
+
40
+ Requires Python 3.11 or later.
41
+
42
+ ---
43
+
44
+ ## Quickstart
45
+
46
+ Start the server in the foreground:
47
+
48
+ ```bash
49
+ agentmetrics-server
50
+ ```
51
+
52
+ Dashboard → **http://localhost:3099**
53
+ API → **http://localhost:8099**
54
+
55
+ Data is stored in `agentmetrics.db` in the current directory by default.
56
+
57
+ ---
58
+
59
+ ## Always running
60
+
61
+ For continuous 24/7 observation across reboots and crashes, install as a system service instead of running in the foreground:
62
+
63
+ ```bash
64
+ agentmetrics install
65
+ ```
66
+
67
+ This registers the server with systemd on Linux, launchd on macOS, or Task Scheduler on Windows, starts it immediately, and configures it to come back automatically after any restart or crash. The database is stored in a persistent OS data directory so it is never lost across reinstalls.
68
+
69
+ ```bash
70
+ agentmetrics install # install and start with defaults
71
+ agentmetrics install --port 9000 # custom port
72
+ agentmetrics install --db postgresql://user:pass@localhost/mydb
73
+
74
+ agentmetrics start # start a stopped service
75
+ agentmetrics stop # stop without uninstalling
76
+ agentmetrics restart # restart
77
+ agentmetrics status # show service state and HTTP health check
78
+ agentmetrics uninstall # remove the service
79
+ ```
80
+
81
+ ### Platform behaviour
82
+
83
+ | Platform | Mechanism | Restarts on crash | Starts on boot |
84
+ |---|---|---|---|
85
+ | Linux (non-root) | systemd user service | yes | yes |
86
+ | Linux (root) | systemd system service | yes | yes |
87
+ | macOS | launchd user agent | yes | yes |
88
+ | Windows | Task Scheduler (ONLOGON) | no | yes (at logon) |
89
+
90
+ On Linux, the service unit is written to `~/.config/systemd/user/agentmetrics.service` when run as a normal user, or `/etc/systemd/system/agentmetrics.service` when run as root. Logs are available via `journalctl --user -u agentmetrics -f`.
91
+
92
+ On macOS, the plist is written to `~/Library/LaunchAgents/com.agentmetrics.server.plist` and logs go to `~/Library/Logs/AgentMetrics/`.
93
+
94
+ ---
95
+
96
+ ## Configuration
97
+
98
+ All options can be passed as flags or set via environment variables.
99
+
100
+ | Flag | Env var | Default | Description |
101
+ |---|---|---|---|
102
+ | `--port` | `PORT` | `8099` | Port to bind |
103
+ | `--host` | — | `0.0.0.0` | Host to bind |
104
+ | `--db` | `DATABASE_URL` | `sqlite:///./agentmetrics.db` | Database URL |
105
+ | `--open` | — | off | Open browser on startup |
106
+
107
+ ### SQLite (default)
108
+
109
+ No setup needed. The database is created automatically in the current directory, or in the OS data directory when running as a service.
110
+
111
+ ### PostgreSQL
112
+
113
+ ```bash
114
+ pip install "agentmetrics-server[postgres]"
115
+ agentmetrics-server --db postgresql://user:pass@localhost/agentmetrics
116
+ ```
117
+
118
+ Or as a persistent service:
119
+
120
+ ```bash
121
+ agentmetrics install --db postgresql://user:pass@localhost/agentmetrics
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Foreground options
127
+
128
+ ```bash
129
+ agentmetrics-server --port 9000
130
+ agentmetrics-server --host 127.0.0.1 # localhost only
131
+ agentmetrics-server --db postgresql://user:pass@localhost/mydb
132
+ agentmetrics-server --open # open dashboard in browser on start
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Health check
138
+
139
+ ```
140
+ GET /health → {"status": "ok"}
141
+ ```
142
+
143
+ ---
144
+
145
+ ## License
146
+
147
+ [MIT](../LICENSE)
@@ -0,0 +1,63 @@
1
+ alembic.ini,sha256=W1pxwN0NCJonFOHZA5e9yl8gkCLPZTjhFLfVDMDIhog,619
2
+ cli.py,sha256=jElRU1V8s1e8WwX0ugrFJgwRvThSGFbjr9Gxc2JYPi0,5381
3
+ daemon.py,sha256=fNAyqVtNcIaT9eNSVj57AZOEnRkQ0MWDEzOLxCIl5hE,13839
4
+ alembic/env.py,sha256=Qovg_hDps7_PmYVtaZd7_ogy2AmoMXsC7mR1OO0qxWQ,1276
5
+ alembic/script.py.mako,sha256=d1EYeVZAj3pkdC4L90bQaE0kATv-EdGJXZNtZ1faF2Y,594
6
+ alembic/versions/001_initial_schema.py,sha256=syr6mhn9jNrgU2KN7H7xMDfMhg3-2wXOCRVkvk5d8RA,2707
7
+ alembic/versions/002_placeholder.py,sha256=5qbQN0dASZnlGa0RnF-1hz39if6M7wNpMzL2SVPzbNg,375
8
+ alembic/versions/003_password_reset.py,sha256=FN_5dht5obNrxh3rgYa_1dmlJ4Yp5ogm4jaIW48cPQ8,1071
9
+ alembic/versions/004_data_integrity.py,sha256=em9_M9pkBg94p-a2z60ahhGla4okZc4wYfmDnaRam8w,886
10
+ alembic/versions/005_agents_metadata.py,sha256=XUoMpE4y8Hr9mF6fEBflYBu_l3vFWjEWKi0ugVtRhbg,2334
11
+ alembic/versions/006_steps_table.py,sha256=SEbNazErMeJfIfaQAqkvzcp3EA_251ihZCT7TqdrjQw,2487
12
+ alembic/versions/007_metrics_aggregation.py,sha256=zD3qZBTuPyv8xIb9nDoKMx5zMAqvZNOqf3zNb_C9EyM,3646
13
+ alembic/versions/008_recommendations_alerts.py,sha256=PLLgPuXMDL_ZcN5khjpw2kB1IPt1iqzoM_Q_G4arG3I,4865
14
+ alembic/versions/009_org_settings.py,sha256=uDUh4vJ_iBqJbkqVZZs6ecfJPlFS2-UA0UxT8tuuG2w,745
15
+ alembic/versions/010_schema_fixes.py,sha256=wX65tt-G-U3MYBkHb7eCu2WsoL3TgpWUOOpA5-qN8yM,2256
16
+ alembic/versions/011_perf_indexes.py,sha256=sb7lJRntM3oHKPgC2ojUuvsN1SXI9R96_yfZjDQ6qOs,915
17
+ alembic/versions/012_v2_schema_promotion.py,sha256=Y6t7a7DNyK62-OLLlcCgVFEPCbxhBGZtYTvnnKAW1V4,2333
18
+ alembic/versions/013_remove_supabase_add_password.py,sha256=hs2qSRPL8jHt2EyPi291K0iNzTMRbLk1XjVhGHbJlWc,1366
19
+ alembic/versions/014_schema_hardening.py,sha256=AqEsJy43PQY3claMK1m7vh2GqzRiBlzuGACISld3fD0,1326
20
+ alembic/versions/015_api_key_auth.py,sha256=uJ4drhok40JSlkT6kuqdoZcivOqXwPQi-QpPDn4ILEM,627
21
+ app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
+ app/config.py,sha256=-uZg-KHJd0xAYMVs-NBYDPACLEQ4bqJSeene7TVp95g,715
23
+ app/database.py,sha256=m_xQysM6MYQAhPBAok3GppBWgIWMrhJ-a39Icl0WuFE,763
24
+ app/db_compat.py,sha256=EE-t2XcbQqNmU7tXNQcARLsxBtcudnUF2ylfwVcpDKU,2908
25
+ app/deps.py,sha256=GAuT2B0v33fKO4AKKfQvMnX_Aln11u2BE8_wtqUp16A,2275
26
+ app/hooks.py,sha256=AF8N0s1SQtXFAdfIVn1W4YFgsfPhQSgffdtZeB_BiGE,2880
27
+ app/main.py,sha256=MciC3S-taz3rkBXfp2vG6tX2hXqT69YfyhMu-PJkA68,10580
28
+ app/worker.py,sha256=00ZNeAvaG3kKzIjxv99bIsm7rLZboI0o2AJi9oABzGc,4883
29
+ app/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
+ app/core/activity_store.py,sha256=ITVY8WhBLn_6apnXfZ_F8qCDjX4T1umJlRLsR0y0K7w,1999
31
+ app/core/pricing.py,sha256=vpY7OWjV2FaHFtkl3LR0pqG03gankjvjQNsADyIW_mg,10431
32
+ app/core/rate_limit.py,sha256=OjYu1Ep9vTwHL86kypNW-84X2CvUsH2bXtosHIa5TWQ,1450
33
+ app/models/__init__.py,sha256=i4OKfHSmODLge51tDaWSwESex5EYSO0p62EWF-wU_ZM,213
34
+ app/models/event.py,sha256=mwWt6KGNLpmj1zhyUh6vAGSOj6n78VOfiCtRayZLnzs,2053
35
+ app/models/metrics.py,sha256=CGC9OrqtabbE_OCAeFN82KyuGu5W0kceAF1CJcxPOxw,2502
36
+ app/models/organization.py,sha256=TtxpCW1WJpkZ3dLHlK4wwOdrb0U8Kt4CQCxZeJu6Uzc,847
37
+ app/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
+ app/routers/activity.py,sha256=yDNRp9AcyKUiXHaQQ63nbZOXaoUqEbMpPfrQfzPv5FI,3852
39
+ app/routers/agents.py,sha256=WNaKuO1f76AXLmBncWF-TeCrC1wuWtGBFffxZ1_ZhUU,5444
40
+ app/routers/alerts.py,sha256=cPnyb3GiDIwcsrU7NUnaXE-ushXupx_oArrdq8FH0K4,5691
41
+ app/routers/audit.py,sha256=UaqA5olmxht_12n8gnCiLnQm3YfJVSla0DNjvv_FGgU,6207
42
+ app/routers/auth.py,sha256=HrN5ZSQprotClTC5GdfoH-y793xJU93nfVCHSV8-I5A,2686
43
+ app/routers/events.py,sha256=I9Y0b6GWo5wJgXnc0ePK4WCptagX-uT079UHzZWBkwI,5895
44
+ app/routers/fleet.py,sha256=J7ysh92r68urhO1ZDsMnHGTIYicPtnORfMYQlwWOv3E,12554
45
+ app/routers/recommendations.py,sha256=O3bQx42BiqHzP6C5JZoUdkAwnIsJMag-x4NaLshg2go,2462
46
+ app/routers/runs.py,sha256=xLeQ6bVitLnl1_aDm4mIK9biLhf2n-joAneHbAi0L9c,4703
47
+ app/routers/slo.py,sha256=-Yj4b-GsPP3e0Wd11sM-1wcZN0uz1-x-WJcgPDyxeLQ,7088
48
+ app/routers/stats.py,sha256=aKZr8tHKQEw3TpAQMiTOuJgv9CuRnKXiT4IufvJl2Uc,12786
49
+ app/schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
+ app/schemas/activity.py,sha256=ed_yDiLu16HOlrRsNRUF-AE9vjl9tX3w5_vXQBx7kN8,1095
51
+ app/schemas/agent.py,sha256=FfqkScUmjFt_5cRQUqtHVa1LWw8TRuknMRezwsu1roY,3464
52
+ app/schemas/auth.py,sha256=o4qaftuj7wF4D9AKgXXS4bfmJBbNwP6GVU0ysghAxNM,452
53
+ app/schemas/event.py,sha256=XQomZSHoa9YkkCHlSExajP6qJLWn9i-_3DC8kEBnU4E,3264
54
+ app/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
+ app/services/agent_service.py,sha256=-eL0b9yk4FNWWHWsBffmkBQDpTr9nLzKhCCxwrySIFU,18237
56
+ app/services/aggregation_service.py,sha256=8spG75kupGt-u8gsHHu2jOLbvcGV9GnmOMcgNmMXO9Q,15410
57
+ app/services/alert_service.py,sha256=zxYRdZ8VDDMMqWz-4sf1kQI6xC-GM0zgvrLk-V94PZM,12989
58
+ app/services/email_service.py,sha256=fhmtoCTGRoAreeHS7vuq4xJyf3KkvYD8vJJ0XHiXDZs,7913
59
+ app/services/recommendation_service.py,sha256=ItzNGELOPcXEQJrI58KVmidwf4ChfdjConTe-Nfb8Mk,11411
60
+ agentmetrics_server-0.1.0.dist-info/METADATA,sha256=d4DYJPKSH19nNOFBA7Z-BRP2ywfQbrV3sktRfZGKd28,4591
61
+ agentmetrics_server-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
62
+ agentmetrics_server-0.1.0.dist-info/entry_points.txt,sha256=vju4ZEDSrX_RwB_-P4xbDMYH0wmWwGvwVwFcAexnVUs,50
63
+ agentmetrics_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentmetrics-server = cli:serve
alembic/env.py ADDED
@@ -0,0 +1,45 @@
1
+ import os
2
+ import sys
3
+ from logging.config import fileConfig
4
+
5
+ from alembic import context
6
+ from sqlalchemy import engine_from_config, pool
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
9
+
10
+ from app.config import settings
11
+ from app.database import Base
12
+ from app.models import Event, Organization # noqa: F401
13
+
14
+ config = context.config
15
+ config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
16
+
17
+ if config.config_file_name is not None:
18
+ fileConfig(config.config_file_name)
19
+
20
+ target_metadata = Base.metadata
21
+
22
+
23
+ def run_migrations_offline() -> None:
24
+ url = config.get_main_option("sqlalchemy.url")
25
+ context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
26
+ with context.begin_transaction():
27
+ context.run_migrations()
28
+
29
+
30
+ def run_migrations_online() -> None:
31
+ connectable = engine_from_config(
32
+ config.get_section(config.config_ini_section, {}),
33
+ prefix="sqlalchemy.",
34
+ poolclass=pool.NullPool,
35
+ )
36
+ with connectable.connect() as connection:
37
+ context.configure(connection=connection, target_metadata=target_metadata)
38
+ with context.begin_transaction():
39
+ context.run_migrations()
40
+
41
+
42
+ if context.is_offline_mode():
43
+ run_migrations_offline()
44
+ else:
45
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,25 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ revision: str = ${repr(up_revision)}
15
+ down_revision: Union[str, None] = ${repr(down_revision)}
16
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
17
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
18
+
19
+
20
+ def upgrade() -> None:
21
+ ${upgrades if upgrades else "pass"}
22
+
23
+
24
+ def downgrade() -> None:
25
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,64 @@
1
+ """initial schema
2
+
3
+ Revision ID: 001
4
+ Revises:
5
+ Create Date: 2026-03-26
6
+ """
7
+ from collections.abc import Sequence
8
+
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+ from sqlalchemy.dialects import postgresql
12
+
13
+ revision: str = "001"
14
+ down_revision: str | None = None
15
+ branch_labels: str | Sequence[str] | None = None
16
+ depends_on: str | Sequence[str] | None = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.create_table(
21
+ "organizations",
22
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
23
+ sa.Column("email", sa.String(255), unique=True, nullable=False),
24
+ sa.Column("company_name", sa.String(255), nullable=False),
25
+ sa.Column("hashed_password", sa.String(255), nullable=False),
26
+ sa.Column("plan", sa.String(50), server_default="free", nullable=False),
27
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
28
+ )
29
+ op.create_index("ix_organizations_email", "organizations", ["email"])
30
+
31
+ op.create_table(
32
+ "api_keys",
33
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
34
+ sa.Column("org_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False),
35
+ sa.Column("key_hash", sa.String(255), unique=True, nullable=False),
36
+ sa.Column("name", sa.String(255), server_default="Default"),
37
+ sa.Column("active", sa.Boolean, server_default="true", nullable=False),
38
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
39
+ )
40
+ op.create_index("ix_api_keys_org_id", "api_keys", ["org_id"])
41
+
42
+ op.create_table(
43
+ "events",
44
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
45
+ sa.Column("org_id", postgresql.UUID(as_uuid=True), nullable=False),
46
+ sa.Column("trace_id", sa.String(255), nullable=False),
47
+ sa.Column("agent_id", sa.String(255), nullable=False),
48
+ sa.Column("status", sa.String(50), nullable=False),
49
+ sa.Column("duration_ms", sa.Float, nullable=True),
50
+ sa.Column("cost_usd", sa.Float, server_default="0"),
51
+ sa.Column("model", sa.String(100), nullable=True),
52
+ sa.Column("input_tokens", sa.Float, nullable=True),
53
+ sa.Column("output_tokens", sa.Float, nullable=True),
54
+ sa.Column("error_message", sa.Text, nullable=True),
55
+ sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now()),
56
+ )
57
+ op.create_index("ix_events_timestamp", "events", ["timestamp"])
58
+ op.create_index("ix_events_org_agent_ts", "events", ["org_id", "agent_id", "timestamp"])
59
+
60
+
61
+ def downgrade() -> None:
62
+ op.drop_table("events")
63
+ op.drop_table("api_keys")
64
+ op.drop_table("organizations")
@@ -0,0 +1,20 @@
1
+ """placeholder migration (fills gap between 001 and 003)
2
+
3
+ Revision ID: 002
4
+ Revises: 001
5
+ Create Date: 2026-03-27
6
+ """
7
+ from collections.abc import Sequence
8
+
9
+ revision: str = "002"
10
+ down_revision: str | None = "001"
11
+ branch_labels: str | Sequence[str] | None = None
12
+ depends_on: str | Sequence[str] | None = None
13
+
14
+
15
+ def upgrade() -> None:
16
+ pass
17
+
18
+
19
+ def downgrade() -> None:
20
+ pass
@@ -0,0 +1,41 @@
1
+ """supabase auth migration
2
+
3
+ Revision ID: 003
4
+ Revises: 002
5
+ Create Date: 2026-03-29
6
+ """
7
+ from collections.abc import Sequence
8
+
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+
12
+ revision: str = "003"
13
+ down_revision: str | None = "002"
14
+ branch_labels: str | Sequence[str] | None = None
15
+ depends_on: str | Sequence[str] | None = None
16
+
17
+
18
+ def upgrade() -> None:
19
+ # Drop custom auth columns no longer needed
20
+ op.drop_column("organizations", "hashed_password")
21
+
22
+ # Add Supabase user ID for JWT-based auth
23
+ op.add_column(
24
+ "organizations",
25
+ sa.Column("supabase_user_id", sa.String(255), nullable=True),
26
+ )
27
+ op.create_index(
28
+ "ix_organizations_supabase_user_id",
29
+ "organizations",
30
+ ["supabase_user_id"],
31
+ unique=True,
32
+ )
33
+
34
+
35
+ def downgrade() -> None:
36
+ op.drop_index("ix_organizations_supabase_user_id", table_name="organizations")
37
+ op.drop_column("organizations", "supabase_user_id")
38
+ op.add_column(
39
+ "organizations",
40
+ sa.Column("hashed_password", sa.String(255), nullable=False, server_default=""),
41
+ )
@@ -0,0 +1,34 @@
1
+ """data integrity: FK on events, indexes, token type fix
2
+
3
+ Revision ID: 004
4
+ Revises: 003
5
+ Create Date: 2026-03-31
6
+ """
7
+ from collections.abc import Sequence
8
+
9
+ from alembic import op
10
+
11
+ revision: str = "004"
12
+ down_revision: str | None = "003"
13
+ branch_labels: str | Sequence[str] | None = None
14
+ depends_on: str | Sequence[str] | None = None
15
+
16
+
17
+ def upgrade() -> None:
18
+ # Add FK from events.org_id → organizations.id with cascade delete
19
+ op.create_foreign_key(
20
+ "fk_events_org_id",
21
+ "events",
22
+ "organizations",
23
+ ["org_id"],
24
+ ["id"],
25
+ ondelete="CASCADE",
26
+ )
27
+
28
+ # Add standalone index on events.org_id for fast per-org queries
29
+ op.create_index("ix_events_org_id", "events", ["org_id"])
30
+
31
+
32
+ def downgrade() -> None:
33
+ op.drop_index("ix_events_org_id", table_name="events")
34
+ op.drop_constraint("fk_events_org_id", "events", type_="foreignkey")
@@ -0,0 +1,65 @@
1
+ """agents table + metadata JSONB on events
2
+
3
+ Revision ID: 005
4
+ Revises: 004
5
+ Create Date: 2026-03-31
6
+ """
7
+ from collections.abc import Sequence
8
+
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+ from sqlalchemy import inspect
12
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
13
+
14
+ revision: str = "005"
15
+ down_revision: str | None = "004"
16
+ branch_labels: str | Sequence[str] | None = None
17
+ depends_on: str | Sequence[str] | None = None
18
+
19
+
20
+ def _tables():
21
+ return inspect(op.get_bind()).get_table_names()
22
+
23
+ def _columns(table):
24
+ return [c["name"] for c in inspect(op.get_bind()).get_columns(table)]
25
+
26
+ def _indexes(table):
27
+ return [i["name"] for i in inspect(op.get_bind()).get_indexes(table)]
28
+
29
+
30
+ def upgrade() -> None:
31
+ tables = _tables()
32
+
33
+ if "agents" not in tables:
34
+ op.create_table(
35
+ "agents",
36
+ sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
37
+ sa.Column("org_id", UUID(as_uuid=True), sa.ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False),
38
+ sa.Column("agent_id", sa.String(255), nullable=False),
39
+ sa.Column("display_name", sa.String(255), nullable=True),
40
+ sa.Column("description", sa.Text, nullable=True),
41
+ sa.Column("environment", sa.String(50), nullable=True),
42
+ sa.Column("tags", JSONB, nullable=True),
43
+ sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.text("now()")),
44
+ sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.text("now()")),
45
+ )
46
+ if "ix_agents_org_agent" not in _indexes("agents"):
47
+ op.create_index("ix_agents_org_agent", "agents", ["org_id", "agent_id"], unique=True)
48
+
49
+ existing_cols = _columns("events")
50
+ for col, typ in [
51
+ ("metadata", JSONB),
52
+ ("step_count", sa.Integer),
53
+ ("tool_calls", sa.Integer),
54
+ ("environment", sa.String(50)),
55
+ ("version", sa.String(50)),
56
+ ]:
57
+ if col not in existing_cols:
58
+ op.add_column("events", sa.Column(col, typ, nullable=True))
59
+
60
+
61
+ def downgrade() -> None:
62
+ for col in ("version", "environment", "tool_calls", "step_count", "metadata"):
63
+ op.drop_column("events", col)
64
+ op.drop_index("ix_agents_org_agent", table_name="agents")
65
+ op.drop_table("agents")
@@ -0,0 +1,59 @@
1
+ """steps table: per-step tracing within an agent run
2
+
3
+ Revision ID: 006
4
+ Revises: 005
5
+ Create Date: 2026-03-31
6
+ """
7
+ from collections.abc import Sequence
8
+
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+ from sqlalchemy import inspect
12
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
13
+
14
+ revision: str = "006"
15
+ down_revision: str | None = "005"
16
+ branch_labels: str | Sequence[str] | None = None
17
+ depends_on: str | Sequence[str] | None = None
18
+
19
+
20
+ def _tables():
21
+ return inspect(op.get_bind()).get_table_names()
22
+
23
+ def _indexes(table):
24
+ return [i["name"] for i in inspect(op.get_bind()).get_indexes(table)]
25
+
26
+
27
+ def upgrade() -> None:
28
+ if "steps" not in _tables():
29
+ op.create_table(
30
+ "steps",
31
+ sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
32
+ sa.Column("event_id", UUID(as_uuid=True), sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False),
33
+ sa.Column("org_id", UUID(as_uuid=True), sa.ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False),
34
+ sa.Column("trace_id", sa.String(255), nullable=False),
35
+ sa.Column("step_name", sa.String(255), nullable=False),
36
+ sa.Column("step_type", sa.String(50), nullable=True),
37
+ sa.Column("status", sa.String(50), nullable=False),
38
+ sa.Column("duration_ms", sa.Float, nullable=True),
39
+ sa.Column("model", sa.String(100), nullable=True),
40
+ sa.Column("input_tokens", sa.Integer, nullable=True),
41
+ sa.Column("output_tokens", sa.Integer, nullable=True),
42
+ sa.Column("cost_usd", sa.Float, default=0.0),
43
+ sa.Column("error_message", sa.Text, nullable=True),
44
+ sa.Column("metadata", JSONB, nullable=True),
45
+ sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
46
+ sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
47
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
48
+ )
49
+ indexes = _indexes("steps")
50
+ if "ix_steps_event_id" not in indexes:
51
+ op.create_index("ix_steps_event_id", "steps", ["event_id"])
52
+ if "ix_steps_org_trace" not in indexes:
53
+ op.create_index("ix_steps_org_trace", "steps", ["org_id", "trace_id"])
54
+
55
+
56
+ def downgrade() -> None:
57
+ op.drop_index("ix_steps_org_trace", table_name="steps")
58
+ op.drop_index("ix_steps_event_id", table_name="steps")
59
+ op.drop_table("steps")
@@ -0,0 +1,80 @@
1
+ """hourly metrics aggregation table
2
+
3
+ Revision ID: 007
4
+ Revises: 006
5
+ Create Date: 2026-03-31
6
+ """
7
+ from collections.abc import Sequence
8
+
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+ from sqlalchemy import inspect
12
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
13
+
14
+ revision: str = "007"
15
+ down_revision: str | None = "006"
16
+ branch_labels: str | Sequence[str] | None = None
17
+ depends_on: str | Sequence[str] | None = None
18
+
19
+
20
+ def _tables():
21
+ return inspect(op.get_bind()).get_table_names()
22
+
23
+ def _indexes(table):
24
+ try:
25
+ return [i["name"] for i in inspect(op.get_bind()).get_indexes(table)]
26
+ except Exception:
27
+ return []
28
+
29
+
30
+ def upgrade() -> None:
31
+ tables = _tables()
32
+
33
+ if "metrics_hourly" not in tables:
34
+ op.create_table(
35
+ "metrics_hourly",
36
+ sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
37
+ sa.Column("org_id", UUID(as_uuid=True), sa.ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False),
38
+ sa.Column("agent_id", sa.String(255), nullable=False),
39
+ sa.Column("hour", sa.DateTime(timezone=True), nullable=False),
40
+ sa.Column("run_count", sa.Integer, default=0),
41
+ sa.Column("success_count", sa.Integer, default=0),
42
+ sa.Column("failure_count", sa.Integer, default=0),
43
+ sa.Column("avg_duration_ms", sa.Float, nullable=True),
44
+ sa.Column("p50_duration_ms", sa.Float, nullable=True),
45
+ sa.Column("p95_duration_ms", sa.Float, nullable=True),
46
+ sa.Column("p99_duration_ms", sa.Float, nullable=True),
47
+ sa.Column("total_cost_usd", sa.Float, default=0.0),
48
+ sa.Column("total_input_tokens", sa.BigInteger, default=0),
49
+ sa.Column("total_output_tokens", sa.BigInteger, default=0),
50
+ sa.Column("cost_by_model", JSONB, nullable=True),
51
+ sa.Column("error_rate", sa.Float, nullable=True),
52
+ sa.Column("loop_count", sa.Integer, default=0),
53
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
54
+ )
55
+ indexes = _indexes("metrics_hourly")
56
+ if "ix_metrics_hourly_org_agent_hour" not in indexes:
57
+ op.create_index("ix_metrics_hourly_org_agent_hour", "metrics_hourly", ["org_id", "agent_id", "hour"], unique=True)
58
+ if "ix_metrics_hourly_org_hour" not in indexes:
59
+ op.create_index("ix_metrics_hourly_org_hour", "metrics_hourly", ["org_id", "hour"])
60
+
61
+ if "monthly_usage" not in tables:
62
+ op.create_table(
63
+ "monthly_usage",
64
+ sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
65
+ sa.Column("org_id", UUID(as_uuid=True), sa.ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False),
66
+ sa.Column("year_month", sa.String(7), nullable=False),
67
+ sa.Column("event_count", sa.Integer, default=0),
68
+ sa.Column("total_cost_usd", sa.Float, default=0.0),
69
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
70
+ )
71
+ if "ix_monthly_usage_org_month" not in _indexes("monthly_usage"):
72
+ op.create_index("ix_monthly_usage_org_month", "monthly_usage", ["org_id", "year_month"], unique=True)
73
+
74
+
75
+ def downgrade() -> None:
76
+ op.drop_index("ix_monthly_usage_org_month", table_name="monthly_usage")
77
+ op.drop_table("monthly_usage")
78
+ op.drop_index("ix_metrics_hourly_org_hour", table_name="metrics_hourly")
79
+ op.drop_index("ix_metrics_hourly_org_agent_hour", table_name="metrics_hourly")
80
+ op.drop_table("metrics_hourly")