Skip to content

Commit 451331a

Browse files
Merge pull request #231 from cortexapps/worktree-cx-2-ai-spend-metrics
feat: AI spend solution with custom metrics, scorecard, and team plugin
2 parents 509208a + cea204e commit 451331a

23 files changed

Lines changed: 2101 additions & 4 deletions

cortexapps_cli/commands/backup.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,47 @@ def import_relationships_file(file_info):
471471

472472
return ("entity-relationships", len(results) - failed_count, [(fp, et, em) for rt, fp, et, em in results if et])
473473

474+
def _import_custom_metrics(ctx, directory):
475+
imported = 0
476+
failed = []
477+
if os.path.isdir(directory):
478+
print("Processing: " + directory)
479+
client = ctx.obj["client"]
480+
for filename in sorted(os.listdir(directory)):
481+
if not filename.endswith(".json"):
482+
continue
483+
file_path = os.path.join(directory, filename)
484+
if not os.path.isfile(file_path):
485+
continue
486+
metric_key = filename[:-5] # strip .json
487+
try:
488+
print(" Importing: " + filename)
489+
with open(file_path) as f:
490+
data = json.load(f)
491+
492+
# Group flat values list by entityTag
493+
grouped = {}
494+
for entry in data.get("values", []):
495+
tag = entry["entityTag"]
496+
if tag not in grouped:
497+
grouped[tag] = []
498+
grouped[tag].append({
499+
"timestamp": entry["timestamp"],
500+
"value": entry["value"],
501+
})
502+
503+
# Call per-entity bulk endpoint once per entity
504+
for entity_tag, series in grouped.items():
505+
client.post(
506+
f"api/v1/eng-intel/custom-metrics/{metric_key}/entity/{entity_tag}/bulk",
507+
data={"series": series},
508+
)
509+
imported += 1
510+
except Exception as e:
511+
print(f" Failed to import {filename}: {type(e).__name__} - {str(e)}")
512+
failed.append((file_path, type(e).__name__, str(e)))
513+
return ("custom-metrics", imported, failed)
514+
474515
def _has_relationships(file_path):
475516
"""Check if a catalog YAML file contains x-cortex-relationships."""
476517
try:
@@ -760,6 +801,7 @@ def import_tenant(
760801
all_stats.append(_import_entity_relationship_types(ctx, directory + "/entity-relationship-types"))
761802
all_stats.append(_import_catalog(ctx, directory + "/catalog"))
762803
all_stats.append(_import_entity_relationships(ctx, directory + "/entity-relationships"))
804+
all_stats.append(_import_custom_metrics(ctx, directory + "/custom-metrics"))
763805
all_stats.append(_import_plugins(ctx, directory + "/plugins"))
764806
all_stats.append(_import_scorecards(ctx, directory + "/scorecards"))
765807
all_stats.append(_import_workflows(ctx, directory + "/workflows"))
@@ -811,6 +853,8 @@ def import_tenant(
811853
elif import_type == "entity-relationships":
812854
# These need special handling - would need the relationship type
813855
print(f"# Manual retry needed for entity-relationships: {file_path}")
856+
elif import_type == "custom-metrics":
857+
print(f"# Manual retry needed for custom-metrics: {file_path}")
814858
elif import_type == "plugins":
815859
print(f"cortex plugins create --force -f \"{file_path}\"")
816860
elif import_type == "scorecards":

cortexapps_cli/commands/plugins.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ def create(
8787
# Remove the 'tag' attribute if it exists
8888
data.pop("tag", None)
8989
r = client.put("api/v1/plugins/" + tag, data, raw_response=True)
90+
else:
91+
r = client.post("api/v1/plugins", data, raw_response=True)
9092
else:
9193
r = client.post("api/v1/plugins", data, raw_response=True)
9294

@@ -142,12 +144,18 @@ def get(
142144
def replace(
143145
ctx: typer.Context,
144146
file_input: Annotated[typer.FileText, typer.Option("--file", "-f", help="File containing contents of plugin using schema defined at https://docs.cortex.io/docs/api/create-plugin")] = None,
145-
tag_or_id: str = typer.Option(..., "--tag-or-id", "-t", help="The tag (x-cortex-tag) or unique, auto-generated identifier for the entity.")
147+
tag_or_id: str = typer.Option(None, "--tag-or-id", "-t", help="The tag or ID of the plugin to replace. Defaults to the tag field in the file."),
146148
):
147149
"""
148150
Replace an existing plugin by tag
149151
"""
150152

151153
client = ctx.obj["client"]
152-
153-
client.put("api/v1/plugins/"+ tag_or_id, data=file_input.read())
154+
155+
data = json.loads(file_input.read())
156+
resolved = tag_or_id or data.get("tag")
157+
if not resolved:
158+
typer.echo("Error: --tag-or-id is required when the file does not contain a 'tag' field.")
159+
raise typer.Exit(1)
160+
161+
client.put("api/v1/plugins/" + resolved, data=data)

cortexapps_cli/commands/solutions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ def _collect_solution_resources(path: Path) -> dict[str, list[str]]:
280280
"catalog": [],
281281
"scorecards": [],
282282
"workflows": [],
283+
"plugins": [],
283284
}
284285
for kind in resources:
285286
subdir = path / kind
@@ -336,7 +337,7 @@ def _run_uninstall(client, path: Path, yes: bool) -> None:
336337
return
337338

338339
typer.echo("\nThis will remove the following resources:")
339-
for kind in ("workflows", "scorecards", "catalog", "entity-relationship-types", "entity-types"):
340+
for kind in ("workflows", "scorecards", "plugins", "catalog", "entity-relationship-types", "entity-types"):
340341
count = len(resources[kind])
341342
if count:
342343
typer.echo(f" {kind}: {count}")
@@ -352,6 +353,7 @@ def _run_uninstall(client, path: Path, yes: bool) -> None:
352353
steps = [
353354
("workflows", lambda t: f"api/v1/workflows/{t}"),
354355
("scorecards", lambda t: f"api/v1/scorecards/{t}"),
356+
("plugins", lambda t: f"api/v1/plugins/{t}"),
355357
("catalog", lambda t: f"api/v1/catalog/{t}"),
356358
("entity-relationship-types", lambda t: f"api/v1/relationship-types/{t}"),
357359
("entity-types", lambda t: f"api/v1/catalog/definitions/{t}"),
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Sync Claude AI Spend to Cortex
2+
3+
on:
4+
schedule:
5+
- cron: "0 6 * * 1" # Every Monday at 06:00 UTC
6+
workflow_dispatch: # Allow manual runs from the Actions tab
7+
8+
jobs:
9+
sync:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- name: Checkout
13+
uses: actions/checkout@v4
14+
15+
- name: Set up Python
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.11"
19+
20+
- name: Install dependencies
21+
run: pip install requests
22+
23+
- name: Sync Claude spend to Cortex
24+
env:
25+
ANTHROPIC_ANALYTICS_KEY: ${{ secrets.ANTHROPIC_ANALYTICS_KEY }}
26+
CORTEX_API_KEY: ${{ secrets.CORTEX_API_KEY }}
27+
run: python scripts/sync-claude-spend.py
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
---
2+
name: AI Spend
3+
description: Track per-employee Claude AI spend in Cortex using custom metrics, with a full team hierarchy for rollup visibility.
4+
---
5+
6+
# AI Spend
7+
8+
Answers the question: **"How much are we spending on Claude AI, and who's spending it?"**
9+
10+
Register every employee as a Cortex entity linked to their team, push weekly Claude spend as a custom metric, and roll costs up the org hierarchy — from individual → sub-team → top-level engineering.
11+
12+
## How It Works
13+
14+
```
15+
┌─────────────────────┐ every Monday 06:00 UTC
16+
│ GitHub Actions │◄──────────────────────────────────┐
17+
│ sync-claude-spend │ │
18+
└────────┬────────────┘ (cron schedule)
19+
20+
│ GET /v1/organizations/analytics/costs
21+
22+
┌─────────────────────┐
23+
│ Anthropic Claude │ per-user spend for the week
24+
│ Analytics API │
25+
└────────┬────────────┘
26+
27+
│ map email → employee-first-last
28+
│ sum members → team rollups
29+
30+
┌─────────────────────┐
31+
│ Cortex API │ POST ai-spend custom metric
32+
│ Custom Metrics │ per employee + per team
33+
└────────┬────────────┘
34+
35+
36+
┌──────────────────────────────────────────────┐
37+
│ Cortex Catalog │
38+
│ │
39+
│ team-engineering $1,212/wk Silver │
40+
│ ├── team-platform $473/wk Gold │
41+
│ │ ├── employee-alice $291/wk │
42+
│ │ └── employee-bob $182/wk │
43+
│ ├── team-frontend $380/wk Silver │
44+
│ │ ├── employee-carol $245/wk │
45+
│ │ └── employee-david $136/wk │
46+
│ └── team-data $359/wk Bronze │
47+
│ └── employee-emma $359/wk │
48+
│ │
49+
│ Scorecard: ai-spend-scorecard │
50+
│ Plugin: team-ai-spend (per-team chart) │
51+
└──────────────────────────────────────────────┘
52+
```
53+
54+
## What's Included
55+
56+
| Resource | Tag / Key |
57+
|---|---|
58+
| Entity type | `employee` |
59+
| Relationship type | `team-member` (team → team\|employee) |
60+
| Teams | `team-engineering`, `team-platform`, `team-frontend`, `team-data` |
61+
| Employees | `employee-alice-chen`, `employee-bob-martinez`, `employee-carol-kim`, `employee-david-osei`, `employee-emma-johnson` |
62+
| Custom metric sample data | `ai-spend` (8 weeks, fictional, per-employee and team rollups) |
63+
| Plugin | `team-ai-spend` (team-scoped spend visualization) |
64+
| Scorecard | `ai-spend-scorecard` (bronze/silver/gold budget compliance) |
65+
| Sync script | `scripts/sync-claude-spend.py` |
66+
| GH Actions workflow | `.github/workflows/sync-claude-spend.yaml` |
67+
68+
## Prerequisites
69+
70+
Before installing, create the `ai-spend` custom metric definition in your Cortex instance:
71+
**Eng Intel → Custom Metrics → New Metric**, key: `ai-spend`.
72+
73+
## Installation
74+
75+
```
76+
cortex solutions install -s ai-spend
77+
```
78+
79+
## After Installing
80+
81+
**Create the team-member catalog**
82+
83+
Enable the relationship type catalog so you can browse team membership from the Cortex UI:
84+
85+
1. Go to **Settings → Entity Relationship Types → team-member**
86+
2. Click **Edit** and enable **Create relationship type catalog**
87+
3. Save
88+
89+
**View the AI Spend Budget Compliance scorecard**
90+
91+
An `ai-spend-scorecard` is installed automatically and tracks whether each team's weekly spend stays within budget:
92+
93+
- **Bronze** — team has `ai-spend` metric data in the last 8 days and a budget set
94+
- **Silver** — spend is within 25% of budget
95+
- **Gold** — spend is at or under budget
96+
97+
The sample data is pre-loaded with budgets that produce an interesting distribution: team-platform achieves Gold, team-frontend and team-engineering achieve Silver, and team-data achieves Bronze.
98+
99+
To set a budget for a real team, add `ai-budget-weekly` as custom data on the team entity:
100+
101+
```bash
102+
cortex custom-data add -t <team-tag> -k ai-budget-weekly -v <weekly-budget-dollars>
103+
```
104+
105+
**View the Team AI Spend plugin**
106+
107+
A `team-ai-spend` plugin is installed automatically and appears on every team entity page. It shows the team's total weekly AI spend and a per-member breakdown bar chart, pulling live data from the `ai-spend` custom metric.
108+
109+
**Create a Tabular View for AI spend**
110+
111+
Build a Data Explorer table to compare spend across employees and teams:
112+
113+
1. Go to **Eng Intelligence → Data Explorer**
114+
2. Select the **Table** view
115+
3. Click **Add column**, find `ai-spend` under the **Custom** category, and click **View metric**
116+
4. Set **Group by → Team** and enable **Show hierarchy** to roll up spend to team level
117+
5. Click **Save As** to name and save the view for future use
118+
119+
> Note: Tabular View creation is not yet available via API. It must be configured manually.
120+
121+
**Set up live Claude spend sync**
122+
123+
The sample entities include fictional spend data. To push real data from your Anthropic Claude Enterprise account weekly:
124+
125+
1. **Get an Analytics API key:**
126+
- Sign in to claude.ai as the **primary owner** of your organization
127+
- Go to **Organization settings → API**
128+
- Enable public API access and create an Analytics API key
129+
- (Only the primary owner can create this key — admin role is not sufficient)
130+
131+
2. **Add secrets to your GitHub repo:**
132+
- `ANTHROPIC_ANALYTICS_KEY` — the Analytics API key from step 1
133+
- `CORTEX_API_KEY` — your Cortex API key
134+
135+
3. **Copy the workflow** to your repo's `.github/workflows/` directory:
136+
```bash
137+
cp .github/workflows/sync-claude-spend.yaml <your-repo>/.github/workflows/
138+
```
139+
140+
4. **Copy the script** to your repo's `scripts/` directory:
141+
```bash
142+
cp scripts/sync-claude-spend.py <your-repo>/scripts/
143+
```
144+
145+
The workflow runs every Monday at 06:00 UTC and can be triggered manually from the GitHub Actions tab.
146+
147+
**Customize the email domain**
148+
149+
The sync script maps `first.last@cortex.io``employee-first-last`. Set `EMAIL_DOMAIN` in the workflow env to match your company's domain:
150+
151+
```yaml
152+
env:
153+
EMAIL_DOMAIN: yourcompany.com
154+
```
155+
156+
**Add your real employees**
157+
158+
The sample entities are fictional. Add your real employees as catalog entities with `x-cortex-type: employee` and tag them `employee-<first>-<last>` to match the email mapping.
159+
160+
**Notes**
161+
162+
- Users who authenticate Claude Code with a personal API key (not Enterprise OAuth) show $0 spend in the Analytics API and are skipped automatically.
163+
- Cost data may take up to 24 hours to appear; dates at least 30 days old are considered final for billing purposes.
164+
- The `ai-spend` custom metric definition must currently be created manually before installing. A future release will support auto-creation of custom metric definitions as part of `cortex solutions install`.
165+
- The scorecard's Bronze rule uses a `P1Y` lookback to accommodate sample data. Once your weekly sync is running consistently, consider tightening it to `P8D` to ensure the rule only passes when data is fresh. The Silver and Gold rules use `P8D` and can similarly be adjusted to match your sync frequency.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Alice Chen
4+
x-cortex-tag: employee-alice-chen
5+
x-cortex-type: employee
6+
x-cortex-description: Platform Engineer
7+
x-cortex-definition: {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Bob Martinez
4+
x-cortex-tag: employee-bob-martinez
5+
x-cortex-type: employee
6+
x-cortex-description: Platform Engineer
7+
x-cortex-definition: {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Carol Kim
4+
x-cortex-tag: employee-carol-kim
5+
x-cortex-type: employee
6+
x-cortex-description: Frontend Engineer
7+
x-cortex-definition: {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: David Osei
4+
x-cortex-tag: employee-david-osei
5+
x-cortex-type: employee
6+
x-cortex-description: Frontend Engineer
7+
x-cortex-definition: {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
openapi: "3.0.0"
2+
info:
3+
title: Emma Johnson
4+
x-cortex-tag: employee-emma-johnson
5+
x-cortex-type: employee
6+
x-cortex-description: Data Engineer
7+
x-cortex-definition: {}

0 commit comments

Comments
 (0)