il-audit 0.1.0__tar.gz
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.
- il_audit-0.1.0/PKG-INFO +91 -0
- il_audit-0.1.0/README.md +71 -0
- il_audit-0.1.0/il_audit/__init__.py +20 -0
- il_audit-0.1.0/il_audit/beacon_router.py +60 -0
- il_audit-0.1.0/il_audit/beacon_schemas.py +22 -0
- il_audit-0.1.0/il_audit/bq_client.py +119 -0
- il_audit-0.1.0/il_audit/builders/__init__.py +5 -0
- il_audit-0.1.0/il_audit/builders/shared.py +45 -0
- il_audit-0.1.0/il_audit/config.py +117 -0
- il_audit-0.1.0/il_audit/context.py +17 -0
- il_audit-0.1.0/il_audit/cost_calculator.py +50 -0
- il_audit-0.1.0/il_audit/cost_catalog.py +48 -0
- il_audit-0.1.0/il_audit/cost_rates_loader.py +179 -0
- il_audit-0.1.0/il_audit/data/audit_defaults.json +9 -0
- il_audit-0.1.0/il_audit/data/cost_rates_seed.json +66 -0
- il_audit-0.1.0/il_audit/emit.py +169 -0
- il_audit-0.1.0/il_audit/envelope.py +51 -0
- il_audit-0.1.0/il_audit/registry.py +92 -0
- il_audit-0.1.0/il_audit/schemas/__init__.py +278 -0
- il_audit-0.1.0/il_audit/static/audit_beacon.js +80 -0
- il_audit-0.1.0/il_audit/taxonomy.py +54 -0
- il_audit-0.1.0/il_audit/writer.py +161 -0
- il_audit-0.1.0/il_audit.egg-info/PKG-INFO +91 -0
- il_audit-0.1.0/il_audit.egg-info/SOURCES.txt +28 -0
- il_audit-0.1.0/il_audit.egg-info/dependency_links.txt +1 -0
- il_audit-0.1.0/il_audit.egg-info/requires.txt +6 -0
- il_audit-0.1.0/il_audit.egg-info/top_level.txt +1 -0
- il_audit-0.1.0/pyproject.toml +41 -0
- il_audit-0.1.0/setup.cfg +4 -0
- il_audit-0.1.0/tests/test_il_audit.py +86 -0
il_audit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: il-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-app audit and analytics logging for Locaria Intelligence Layer
|
|
5
|
+
Author: Locaria Intelligence Layer Team
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Keywords: audit,logging,bigquery,analytics,locaria
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: google-cloud-bigquery>=3.27.0
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: fastapi>=0.115.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# il-audit — Intelligence Layer audit & analytics
|
|
22
|
+
|
|
23
|
+
Cross-app BigQuery audit logging for **intelligence-layer-ingest** and **intelligence-layer-chat**.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Local dev (sibling repo):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install -e ../intelligence-layer-audit
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Production pin (PyPI, after release):
|
|
34
|
+
|
|
35
|
+
```toml
|
|
36
|
+
"il-audit>=0.1.0",
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or from Git:
|
|
40
|
+
|
|
41
|
+
```toml
|
|
42
|
+
"il-audit @ git+https://github.com/Locaria/intelligence-layer-audit@v0.1.0",
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
See [PUBLISHING.md](PUBLISHING.md) for release → PyPI workflow (same as `locaria-integrated-testing`).
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
All defaults live in bundled JSON — **never hardcode prices or dataset names in application code**.
|
|
50
|
+
|
|
51
|
+
| File | Purpose |
|
|
52
|
+
|------|---------|
|
|
53
|
+
| `il_audit/data/audit_defaults.json` | Dataset, flush tuning, default `cost_rates_source` |
|
|
54
|
+
| `il_audit/data/cost_rates_seed.json` | Interim pricing catalog (until BQ `cost_rates` is scraped) |
|
|
55
|
+
| `il_audit/cost_catalog.py` | Stable service/operation/resource identifier constants |
|
|
56
|
+
|
|
57
|
+
| Variable | Default (from JSON) | Purpose |
|
|
58
|
+
|----------|---------------------|---------|
|
|
59
|
+
| `IL_ENABLE_AUDIT_LOGS` | `true` | Master switch (also respects `IL_ENABLE_BQ_LOGS`) |
|
|
60
|
+
| `IL_BQ_AUDIT_DATASET` | `intelligence_layer_logs` | BQ dataset |
|
|
61
|
+
| `IL_BQ_LOGS_PROJECT` | — | GCP project (`GOOGLE_CLOUD_PROJECT` / `GCP_PROJECT` fallback) |
|
|
62
|
+
| `IL_COST_RATES_SOURCE` | `seed` | `seed` or `bigquery` (authoritative catalog from `cost_rates` table) |
|
|
63
|
+
| `IL_COST_RATES_CACHE_SECONDS` | `3600` | In-memory rate cache TTL |
|
|
64
|
+
| `IL_AUDIT_STORE_QUERY_TEXT` | `true` | Store full chat questions in BQ |
|
|
65
|
+
|
|
66
|
+
When `IL_COST_RATES_SOURCE=bigquery`, rates load from `{project}.{dataset}.cost_rates`. A scheduled-tasks job will scrape GCP pricing docs and maintain that table. Until then, seed JSON is used and rows are tagged `pricing_source=seed_catalog`.
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from il_audit import AuditContext, emit
|
|
72
|
+
from il_audit import taxonomy as T
|
|
73
|
+
|
|
74
|
+
emit(
|
|
75
|
+
T.AUTH_LOGIN_SUCCEEDED,
|
|
76
|
+
context=AuditContext(app="ingest", actor_uid=uid, actor_email=email),
|
|
77
|
+
status="succeeded",
|
|
78
|
+
action="login",
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Chat adoption (phase 2)
|
|
83
|
+
|
|
84
|
+
1. Add same `il-audit` dep to chat
|
|
85
|
+
2. Mount `create_beacon_router(app_name="chat", get_context=...)`
|
|
86
|
+
3. Emit `chat_turns` on `POST /query` with client `conversation_session_id`
|
|
87
|
+
4. See `documentation/chat-phase2.md`
|
|
88
|
+
|
|
89
|
+
## SQL
|
|
90
|
+
|
|
91
|
+
Operator-run views: `sql/analytics_views.sql`
|
il_audit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# il-audit — Intelligence Layer audit & analytics
|
|
2
|
+
|
|
3
|
+
Cross-app BigQuery audit logging for **intelligence-layer-ingest** and **intelligence-layer-chat**.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Local dev (sibling repo):
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e ../intelligence-layer-audit
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Production pin (PyPI, after release):
|
|
14
|
+
|
|
15
|
+
```toml
|
|
16
|
+
"il-audit>=0.1.0",
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or from Git:
|
|
20
|
+
|
|
21
|
+
```toml
|
|
22
|
+
"il-audit @ git+https://github.com/Locaria/intelligence-layer-audit@v0.1.0",
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
See [PUBLISHING.md](PUBLISHING.md) for release → PyPI workflow (same as `locaria-integrated-testing`).
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
All defaults live in bundled JSON — **never hardcode prices or dataset names in application code**.
|
|
30
|
+
|
|
31
|
+
| File | Purpose |
|
|
32
|
+
|------|---------|
|
|
33
|
+
| `il_audit/data/audit_defaults.json` | Dataset, flush tuning, default `cost_rates_source` |
|
|
34
|
+
| `il_audit/data/cost_rates_seed.json` | Interim pricing catalog (until BQ `cost_rates` is scraped) |
|
|
35
|
+
| `il_audit/cost_catalog.py` | Stable service/operation/resource identifier constants |
|
|
36
|
+
|
|
37
|
+
| Variable | Default (from JSON) | Purpose |
|
|
38
|
+
|----------|---------------------|---------|
|
|
39
|
+
| `IL_ENABLE_AUDIT_LOGS` | `true` | Master switch (also respects `IL_ENABLE_BQ_LOGS`) |
|
|
40
|
+
| `IL_BQ_AUDIT_DATASET` | `intelligence_layer_logs` | BQ dataset |
|
|
41
|
+
| `IL_BQ_LOGS_PROJECT` | — | GCP project (`GOOGLE_CLOUD_PROJECT` / `GCP_PROJECT` fallback) |
|
|
42
|
+
| `IL_COST_RATES_SOURCE` | `seed` | `seed` or `bigquery` (authoritative catalog from `cost_rates` table) |
|
|
43
|
+
| `IL_COST_RATES_CACHE_SECONDS` | `3600` | In-memory rate cache TTL |
|
|
44
|
+
| `IL_AUDIT_STORE_QUERY_TEXT` | `true` | Store full chat questions in BQ |
|
|
45
|
+
|
|
46
|
+
When `IL_COST_RATES_SOURCE=bigquery`, rates load from `{project}.{dataset}.cost_rates`. A scheduled-tasks job will scrape GCP pricing docs and maintain that table. Until then, seed JSON is used and rows are tagged `pricing_source=seed_catalog`.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from il_audit import AuditContext, emit
|
|
52
|
+
from il_audit import taxonomy as T
|
|
53
|
+
|
|
54
|
+
emit(
|
|
55
|
+
T.AUTH_LOGIN_SUCCEEDED,
|
|
56
|
+
context=AuditContext(app="ingest", actor_uid=uid, actor_email=email),
|
|
57
|
+
status="succeeded",
|
|
58
|
+
action="login",
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Chat adoption (phase 2)
|
|
63
|
+
|
|
64
|
+
1. Add same `il-audit` dep to chat
|
|
65
|
+
2. Mount `create_beacon_router(app_name="chat", get_context=...)`
|
|
66
|
+
3. Emit `chat_turns` on `POST /query` with client `conversation_session_id`
|
|
67
|
+
4. See `documentation/chat-phase2.md`
|
|
68
|
+
|
|
69
|
+
## SQL
|
|
70
|
+
|
|
71
|
+
Operator-run views: `sql/analytics_views.sql`
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Cross-app audit and analytics logging for Locaria Intelligence Layer."""
|
|
2
|
+
|
|
3
|
+
from il_audit.config import AuditSettings, get_audit_settings
|
|
4
|
+
from il_audit.context import AuditContext
|
|
5
|
+
from il_audit.emit import emit, emit_api_usage, emit_batch, emit_resource_usage, schedule_beacon_rows
|
|
6
|
+
from il_audit.registry import register_builder
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuditContext",
|
|
10
|
+
"AuditSettings",
|
|
11
|
+
"emit",
|
|
12
|
+
"emit_api_usage",
|
|
13
|
+
"emit_batch",
|
|
14
|
+
"emit_resource_usage",
|
|
15
|
+
"get_audit_settings",
|
|
16
|
+
"register_builder",
|
|
17
|
+
"schedule_beacon_rows",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""FastAPI router for client UI beacons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
10
|
+
|
|
11
|
+
from il_audit.beacon_schemas import BeaconRequest
|
|
12
|
+
from il_audit.context import AuditContext
|
|
13
|
+
from il_audit.emit import schedule_beacon_rows
|
|
14
|
+
|
|
15
|
+
_beacon_last_flush: dict[str, float] = defaultdict(float)
|
|
16
|
+
_BEACON_MIN_INTERVAL_SECONDS = 5.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_beacon_router(
|
|
20
|
+
*,
|
|
21
|
+
app_name: str,
|
|
22
|
+
get_context: Callable[[Request], AuditContext],
|
|
23
|
+
) -> APIRouter:
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
@router.post("/api/audit/beacon")
|
|
27
|
+
def audit_beacon(request: Request, body: BeaconRequest) -> dict[str, str | int]:
|
|
28
|
+
session_key = request.client.host if request.client else "unknown"
|
|
29
|
+
now = time.monotonic()
|
|
30
|
+
if now - _beacon_last_flush[session_key] < _BEACON_MIN_INTERVAL_SECONDS:
|
|
31
|
+
raise HTTPException(status_code=429, detail="Beacon rate limit exceeded")
|
|
32
|
+
_beacon_last_flush[session_key] = now
|
|
33
|
+
if not body.events:
|
|
34
|
+
return {"status": "ok", "accepted": 0}
|
|
35
|
+
context = get_context(request)
|
|
36
|
+
if context.app != app_name:
|
|
37
|
+
context = AuditContext(
|
|
38
|
+
app=app_name,
|
|
39
|
+
actor_uid=context.actor_uid,
|
|
40
|
+
actor_email=context.actor_email,
|
|
41
|
+
trace_id=context.trace_id,
|
|
42
|
+
request_id=context.request_id,
|
|
43
|
+
source="beacon",
|
|
44
|
+
)
|
|
45
|
+
else:
|
|
46
|
+
context = AuditContext(
|
|
47
|
+
app=context.app,
|
|
48
|
+
actor_uid=context.actor_uid,
|
|
49
|
+
actor_email=context.actor_email,
|
|
50
|
+
trace_id=context.trace_id,
|
|
51
|
+
request_id=context.request_id,
|
|
52
|
+
source="beacon",
|
|
53
|
+
)
|
|
54
|
+
schedule_beacon_rows(
|
|
55
|
+
context=context,
|
|
56
|
+
events=[event.model_dump() for event in body.events],
|
|
57
|
+
)
|
|
58
|
+
return {"status": "ok", "accepted": len(body.events)}
|
|
59
|
+
|
|
60
|
+
return router
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Pydantic models for POST /api/audit/beacon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BeaconEventItem(BaseModel):
|
|
11
|
+
event_type: str
|
|
12
|
+
surface: str | None = None
|
|
13
|
+
element_id: str | None = None
|
|
14
|
+
interaction: str | None = None
|
|
15
|
+
duration_ms: int | None = None
|
|
16
|
+
result_count: int | None = None
|
|
17
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
18
|
+
client_ts: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BeaconRequest(BaseModel):
|
|
22
|
+
events: list[BeaconEventItem] = Field(default_factory=list, max_length=50)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""BigQuery client helpers for audit log writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from google.api_core.exceptions import NotFound
|
|
11
|
+
|
|
12
|
+
_logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_shared_clients: dict[str, Any] = {}
|
|
15
|
+
_client_lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
WRITE_APPEND = "WRITE_APPEND"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _import_bigquery() -> Any:
|
|
21
|
+
try:
|
|
22
|
+
from google.cloud import bigquery
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
"google-cloud-bigquery is required — pip install google-cloud-bigquery",
|
|
26
|
+
) from exc
|
|
27
|
+
return bigquery
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_bigquery_client(*, project: str) -> Any:
|
|
31
|
+
project_id = project.strip()
|
|
32
|
+
if not project_id:
|
|
33
|
+
raise ValueError("BigQuery project id is required")
|
|
34
|
+
with _client_lock:
|
|
35
|
+
cached = _shared_clients.get(project_id)
|
|
36
|
+
if cached is not None:
|
|
37
|
+
return cached
|
|
38
|
+
bigquery = _import_bigquery()
|
|
39
|
+
client = bigquery.Client(project=project_id)
|
|
40
|
+
_shared_clients[project_id] = client
|
|
41
|
+
return client
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_bigquery_query(*, sql: str, project: str) -> list[dict[str, Any]]:
|
|
45
|
+
bigquery = _import_bigquery()
|
|
46
|
+
client = get_bigquery_client(project=project)
|
|
47
|
+
rows = client.query(sql).result()
|
|
48
|
+
return [dict(row.items()) for row in rows]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_bigquery_rows(
|
|
52
|
+
*,
|
|
53
|
+
project: str,
|
|
54
|
+
dataset: str,
|
|
55
|
+
table: str,
|
|
56
|
+
rows: Sequence[Mapping[str, Any]],
|
|
57
|
+
schema: Sequence[Any] | None = None,
|
|
58
|
+
) -> int:
|
|
59
|
+
if not rows:
|
|
60
|
+
return 0
|
|
61
|
+
bigquery = _import_bigquery()
|
|
62
|
+
client = get_bigquery_client(project=project)
|
|
63
|
+
table_ref = f"{project.strip()}.{dataset.strip()}.{table.strip()}"
|
|
64
|
+
payload = [dict(row) for row in rows]
|
|
65
|
+
job_config = bigquery.LoadJobConfig(write_disposition=WRITE_APPEND)
|
|
66
|
+
if schema:
|
|
67
|
+
job_config.schema = list(schema)
|
|
68
|
+
job = client.load_table_from_json(payload, table_ref, job_config=job_config)
|
|
69
|
+
job.result()
|
|
70
|
+
return len(payload)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BigQueryTableManager:
|
|
74
|
+
def __init__(self, *, project_id: str) -> None:
|
|
75
|
+
project = project_id.strip()
|
|
76
|
+
if not project:
|
|
77
|
+
raise ValueError("BigQuery project id is required")
|
|
78
|
+
self.project_id = project
|
|
79
|
+
self._client: Any | None = None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def client(self) -> Any:
|
|
83
|
+
if self._client is None:
|
|
84
|
+
self._client = get_bigquery_client(project=self.project_id)
|
|
85
|
+
return self._client
|
|
86
|
+
|
|
87
|
+
def ensure_dataset_exists(self, dataset_id: str) -> None:
|
|
88
|
+
bigquery = _import_bigquery()
|
|
89
|
+
dataset_ref = f"{self.project_id}.{dataset_id.strip()}"
|
|
90
|
+
try:
|
|
91
|
+
self.client.get_dataset(dataset_ref)
|
|
92
|
+
except NotFound:
|
|
93
|
+
_logger.info("Creating BigQuery dataset %s", dataset_ref)
|
|
94
|
+
self.client.create_dataset(bigquery.Dataset(dataset_ref))
|
|
95
|
+
|
|
96
|
+
def ensure_table_exists(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
dataset_id: str,
|
|
100
|
+
table_id: str,
|
|
101
|
+
schema: Sequence[Any],
|
|
102
|
+
) -> bool:
|
|
103
|
+
bigquery = _import_bigquery()
|
|
104
|
+
table_ref = f"{self.project_id}.{dataset_id.strip()}.{table_id.strip()}"
|
|
105
|
+
schema_list = list(schema)
|
|
106
|
+
try:
|
|
107
|
+
table = self.client.get_table(table_ref)
|
|
108
|
+
existing_names = {field.name for field in table.schema}
|
|
109
|
+
missing = [field for field in schema_list if field.name not in existing_names]
|
|
110
|
+
if missing:
|
|
111
|
+
table.schema = list(table.schema) + missing
|
|
112
|
+
self.client.update_table(table, ["schema"])
|
|
113
|
+
return False
|
|
114
|
+
except NotFound:
|
|
115
|
+
self.ensure_dataset_exists(dataset_id)
|
|
116
|
+
_logger.info("Creating BigQuery table %s", table_ref)
|
|
117
|
+
table = bigquery.Table(table_ref, schema=schema_list)
|
|
118
|
+
self.client.create_table(table)
|
|
119
|
+
return True
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared envelope row builder for domain audit tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from il_audit.context import AuditContext
|
|
8
|
+
from il_audit.envelope import build_envelope
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _module_for_event(event_type: str) -> str:
|
|
12
|
+
prefix = event_type.split(".", 1)[0]
|
|
13
|
+
return prefix
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_envelope_row(
|
|
17
|
+
*,
|
|
18
|
+
context: AuditContext,
|
|
19
|
+
event_type: str,
|
|
20
|
+
metadata: dict[str, Any] | None = None,
|
|
21
|
+
module: str | None = None,
|
|
22
|
+
action: str | None = None,
|
|
23
|
+
status: str | None = None,
|
|
24
|
+
resource_type: str | None = None,
|
|
25
|
+
resource_id: str | None = None,
|
|
26
|
+
batch_id: str | None = None,
|
|
27
|
+
before: dict[str, Any] | None = None,
|
|
28
|
+
after: dict[str, Any] | None = None,
|
|
29
|
+
**extra: Any,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
row = build_envelope(
|
|
32
|
+
context=context,
|
|
33
|
+
module=module or _module_for_event(event_type),
|
|
34
|
+
event_type=event_type,
|
|
35
|
+
action=action,
|
|
36
|
+
status=status,
|
|
37
|
+
resource_type=resource_type,
|
|
38
|
+
resource_id=resource_id,
|
|
39
|
+
batch_id=batch_id,
|
|
40
|
+
before=before,
|
|
41
|
+
after=after,
|
|
42
|
+
metadata=metadata,
|
|
43
|
+
)
|
|
44
|
+
row.update({key: value for key, value in extra.items() if value is not None})
|
|
45
|
+
return row
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Runtime configuration for il_audit — defaults from bundled JSON, overrides via env."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from il_audit.cost_catalog import COST_RATES_SOURCE_SEED
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _env_bool(name: str, default: bool) -> bool:
|
|
14
|
+
raw = os.getenv(name, "").strip().lower()
|
|
15
|
+
if not raw:
|
|
16
|
+
return default
|
|
17
|
+
return raw in {"1", "true", "yes", "on"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _env_int(name: str, default: int) -> int:
|
|
21
|
+
raw = os.getenv(name, "").strip()
|
|
22
|
+
if not raw:
|
|
23
|
+
return default
|
|
24
|
+
try:
|
|
25
|
+
return int(raw)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return default
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _env_float(name: str, default: float) -> float:
|
|
31
|
+
raw = os.getenv(name, "").strip()
|
|
32
|
+
if not raw:
|
|
33
|
+
return default
|
|
34
|
+
try:
|
|
35
|
+
return float(raw)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_bq_project() -> str:
|
|
41
|
+
for key in ("IL_BQ_LOGS_PROJECT", "GOOGLE_CLOUD_PROJECT", "GCP_PROJECT"):
|
|
42
|
+
value = os.getenv(key, "").strip()
|
|
43
|
+
if value:
|
|
44
|
+
return value
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache(maxsize=1)
|
|
49
|
+
def _bundled_defaults() -> dict[str, Any]:
|
|
50
|
+
from il_audit.cost_rates_loader import load_audit_defaults
|
|
51
|
+
|
|
52
|
+
return load_audit_defaults()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class AuditSettings:
|
|
57
|
+
"""BigQuery audit logging settings."""
|
|
58
|
+
|
|
59
|
+
bq_project: str
|
|
60
|
+
bq_dataset: str
|
|
61
|
+
enable_audit_logs: bool
|
|
62
|
+
store_query_text: bool
|
|
63
|
+
flush_batch_size: int
|
|
64
|
+
flush_interval_seconds: float
|
|
65
|
+
cost_rates_source: str
|
|
66
|
+
cost_rates_cache_seconds: int
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_env(cls) -> AuditSettings:
|
|
70
|
+
defaults = _bundled_defaults()
|
|
71
|
+
bq_project = _resolve_bq_project()
|
|
72
|
+
bq_dataset = (
|
|
73
|
+
os.getenv("IL_BQ_AUDIT_DATASET", "").strip()
|
|
74
|
+
or str(defaults.get("bq_audit_dataset") or "")
|
|
75
|
+
)
|
|
76
|
+
enable = _env_bool("IL_ENABLE_AUDIT_LOGS", bool(defaults.get("enable_audit_logs", True)))
|
|
77
|
+
if not _env_bool("IL_ENABLE_BQ_LOGS", True):
|
|
78
|
+
enable = False
|
|
79
|
+
cost_source = (
|
|
80
|
+
os.getenv("IL_COST_RATES_SOURCE", "").strip().lower()
|
|
81
|
+
or str(defaults.get("cost_rates_source") or COST_RATES_SOURCE_SEED)
|
|
82
|
+
)
|
|
83
|
+
return cls(
|
|
84
|
+
bq_project=bq_project,
|
|
85
|
+
bq_dataset=bq_dataset,
|
|
86
|
+
enable_audit_logs=enable,
|
|
87
|
+
store_query_text=_env_bool(
|
|
88
|
+
"IL_AUDIT_STORE_QUERY_TEXT",
|
|
89
|
+
bool(defaults.get("store_query_text", True)),
|
|
90
|
+
),
|
|
91
|
+
flush_batch_size=_env_int(
|
|
92
|
+
"IL_AUDIT_FLUSH_BATCH_SIZE",
|
|
93
|
+
int(defaults.get("flush_batch_size") or 50),
|
|
94
|
+
),
|
|
95
|
+
flush_interval_seconds=_env_float(
|
|
96
|
+
"IL_AUDIT_FLUSH_INTERVAL_SECONDS",
|
|
97
|
+
float(defaults.get("flush_interval_seconds") or 1.0),
|
|
98
|
+
),
|
|
99
|
+
cost_rates_source=cost_source,
|
|
100
|
+
cost_rates_cache_seconds=_env_int(
|
|
101
|
+
"IL_COST_RATES_CACHE_SECONDS",
|
|
102
|
+
int(defaults.get("cost_rates_cache_seconds") or 3600),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@lru_cache(maxsize=1)
|
|
108
|
+
def get_audit_settings() -> AuditSettings:
|
|
109
|
+
return AuditSettings.from_env()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def reset_audit_settings_cache() -> None:
|
|
113
|
+
get_audit_settings.cache_clear()
|
|
114
|
+
_bundled_defaults.cache_clear()
|
|
115
|
+
from il_audit.cost_rates_loader import reset_cost_rates_cache
|
|
116
|
+
|
|
117
|
+
reset_cost_rates_cache()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Audit context passed to every emit call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class AuditContext:
|
|
10
|
+
"""Who performed an action and how to correlate it."""
|
|
11
|
+
|
|
12
|
+
app: str
|
|
13
|
+
actor_uid: str | None = None
|
|
14
|
+
actor_email: str | None = None
|
|
15
|
+
trace_id: str | None = None
|
|
16
|
+
request_id: str | None = None
|
|
17
|
+
source: str = "api"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""USD estimation from the active cost_rates catalog (BQ or seed JSON)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from il_audit.cost_rates_loader import RateEntry, get_active_cost_rates
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _lookup_entry(*, service: str, model_id: str) -> RateEntry | None:
|
|
9
|
+
catalog = get_active_cost_rates()
|
|
10
|
+
return catalog.index().get((service, model_id))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def estimate_api_cost_usd(
|
|
14
|
+
*,
|
|
15
|
+
service: str,
|
|
16
|
+
model_id: str,
|
|
17
|
+
input_tokens: int = 0,
|
|
18
|
+
output_tokens: int = 0,
|
|
19
|
+
) -> tuple[float, str, str]:
|
|
20
|
+
"""
|
|
21
|
+
Estimate API cost from the active catalog.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
(cost_usd, pricing_version, pricing_source)
|
|
25
|
+
"""
|
|
26
|
+
catalog = get_active_cost_rates()
|
|
27
|
+
entry = catalog.index().get((service, model_id))
|
|
28
|
+
if entry is None:
|
|
29
|
+
return 0.0, catalog.rate_version, catalog.pricing_source
|
|
30
|
+
cost = (
|
|
31
|
+
(max(0, input_tokens) * entry.input_price_per_1m_tokens)
|
|
32
|
+
+ (max(0, output_tokens) * entry.output_price_per_1m_tokens)
|
|
33
|
+
) / 1_000_000
|
|
34
|
+
version = entry.rate_version or catalog.rate_version
|
|
35
|
+
return round(cost, 8), version, catalog.pricing_source
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def estimate_unit_cost_usd(
|
|
39
|
+
*,
|
|
40
|
+
service: str,
|
|
41
|
+
model_id: str,
|
|
42
|
+
quantity: float,
|
|
43
|
+
) -> tuple[float, str, str]:
|
|
44
|
+
"""Estimate resource cost from unit-priced catalog entry."""
|
|
45
|
+
catalog = get_active_cost_rates()
|
|
46
|
+
entry = catalog.index().get((service, model_id))
|
|
47
|
+
if entry is None or entry.unit_price is None:
|
|
48
|
+
return 0.0, catalog.rate_version, catalog.pricing_source
|
|
49
|
+
version = entry.rate_version or catalog.rate_version
|
|
50
|
+
return round(max(0.0, quantity) * entry.unit_price, 8), version, catalog.pricing_source
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Stable identifiers for cost attribution — single source, no magic strings elsewhere."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Billable API services (maps to cost_rates.service)
|
|
6
|
+
SERVICE_VERTEX_GEMINI = "vertex_gemini"
|
|
7
|
+
SERVICE_VERTEX_EMBEDDING = "vertex_embedding"
|
|
8
|
+
SERVICE_VERTEX_RERANK = "vertex_rerank"
|
|
9
|
+
SERVICE_DRIVE_API = "drive_api"
|
|
10
|
+
SERVICE_CLOUD_RUN = "cloud_run"
|
|
11
|
+
SERVICE_GCS = "gcs"
|
|
12
|
+
|
|
13
|
+
# API operations (maps to api_usage_events.operation)
|
|
14
|
+
OPERATION_GENERATE_CONTENT = "generate_content"
|
|
15
|
+
OPERATION_EMBED_CONTENT = "embed_content"
|
|
16
|
+
OPERATION_SEMANTIC_RANKER = "semantic_ranker"
|
|
17
|
+
OPERATION_FILES_GET = "files.get"
|
|
18
|
+
|
|
19
|
+
# Ingest token categories (maps to api_usage_events.category)
|
|
20
|
+
API_CATEGORY_VISION = "vision"
|
|
21
|
+
API_CATEGORY_TAGGING = "tagging"
|
|
22
|
+
API_CATEGORY_SUMMARY = "summary"
|
|
23
|
+
API_CATEGORY_EMBEDDING = "embedding"
|
|
24
|
+
|
|
25
|
+
# Correlation types (maps to api_usage_events.correlation_type)
|
|
26
|
+
CORRELATION_INGEST_JOB = "ingest_job"
|
|
27
|
+
CORRELATION_INGEST_FILE = "ingest_file"
|
|
28
|
+
CORRELATION_CHAT_TURN = "chat_turn"
|
|
29
|
+
CORRELATION_DRIVE_PIPELINE = "drive_pipeline"
|
|
30
|
+
CORRELATION_CLIENT_RECONCILE = "client_reconcile"
|
|
31
|
+
|
|
32
|
+
# Resource usage types (maps to resource_usage_events.resource_type)
|
|
33
|
+
RESOURCE_CLOUD_RUN_CPU_SECONDS = "cloud_run_cpu_seconds"
|
|
34
|
+
RESOURCE_GCS_STAGING_BYTES = "gcs_staging_bytes"
|
|
35
|
+
RESOURCE_POSTGRES_CHUNK_WRITES = "postgres_chunk_writes"
|
|
36
|
+
RESOURCE_DRIVE_API_CALLS = "drive_api_calls"
|
|
37
|
+
|
|
38
|
+
# Resource rate keys (maps to cost_rates model_id for unit-priced resources)
|
|
39
|
+
RESOURCE_RATE_CPU_SECOND = "cpu_second"
|
|
40
|
+
RESOURCE_RATE_STORAGE_BYTE = "storage_byte"
|
|
41
|
+
|
|
42
|
+
# Pricing source values stored on usage rows until BQ catalog is authoritative
|
|
43
|
+
PRICING_SOURCE_SEED_CATALOG = "seed_catalog"
|
|
44
|
+
PRICING_SOURCE_BIGQUERY_CATALOG = "bigquery_catalog"
|
|
45
|
+
|
|
46
|
+
# cost_rates loader source (AuditSettings.cost_rates_source)
|
|
47
|
+
COST_RATES_SOURCE_SEED = "seed"
|
|
48
|
+
COST_RATES_SOURCE_BIGQUERY = "bigquery"
|