Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ This repository starts with a fresh history. Releases are listed here once tagge
peppered scrypt too. Production-like environments refuse to boot without
the pepper; local and test use a public dev pepper. See
docs/api-key-hashing.md.
- Optional PostHog product tracking (`POSTHOG_API_KEY`, `POSTHOG_HOST`; off when
unset): identify with email and `signup` on account creation, `activation` once
per account on first key use. Fail-safe and non-blocking. Adds migration 055: run
`alembic upgrade head` before starting the image, or set
`RUN_MIGRATIONS_ON_START=true` so the entrypoint migrates on boot.
- Upstream MCP (Preview): optional multi-tool discovery and exact-name allowlist with
stable public IDs, per-tool permit scope, and unchanged per-call credits.
Health reports `upstream_tool_mode: multi_tool_preview` while it is on.
Expand Down
10 changes: 10 additions & 0 deletions gateway/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ PORT=8000
# PUBLIC_URL=https://api.thisisatest.tech
PUBLIC_URL=

# --- Product analytics (PostHog, optional) ---
# Read from the process environment, then this file (Settings). Leave
# POSTHOG_API_KEY blank (the
# default) and tracking is a no-op. Never commit a real key.
# Events: signup + identify (email) on account creation, activation on the
# account's first key use. See docs/product-analytics.md.
POSTHOG_API_KEY=
# POSTHOG_HOST=https://us.i.posthog.com
POSTHOG_HOST=
Comment thread
PetrefiedThunder marked this conversation as resolved.

# Public OpenAPI contact metadata. Leave blank until these are real, monitored
# operator details; placeholder contact information must never be deployed.
PUBLIC_CONTACT_NAME=
Expand Down
4 changes: 4 additions & 0 deletions gateway/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ def demo_allowed_tools_list(self) -> list[str]:
JEV_RISK_GUARD_TIMEOUT_SECONDS: float = 1.5
JEV_RISK_GUARD_TIERS: str = "medium,high"
TYPESAFE_API_KEY: SecretStr = SecretStr("")
# Optional PostHog product tracking (docs/product-analytics.md). Blank
# key = tracking off. Declared here so .env entries load.
POSTHOG_API_KEY: SecretStr = SecretStr("")
POSTHOG_HOST: str = ""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Server pepper for HMAC-SHA256 API-key digests. Required in
# production-like environments (startup refuses to boot without it).
# See docs/api-key-hashing.md. Never log it.
Expand Down
161 changes: 161 additions & 0 deletions gateway/app/core/product_analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Fail-safe, non-blocking PostHog product tracking.

Tracking is off unless ``POSTHOG_API_KEY`` is set (process env or ``.env`` via
Settings). When it
is unset every function here is a no-op: no client, no network, no task.

Events go to PostHog's public ``/capture/`` endpoint over httpx (already a
gateway dependency), so no PostHog SDK is installed. Each send is scheduled as
a background task with a short timeout, and every exception is caught and
logged at debug level without the API key, so tracking can never fail or slow
a request.

The distinct id is the account's root (sponsor) wallet id. Only
:func:`identify` sends personal data: the ``email`` person property.
"""

from __future__ import annotations

import asyncio
import logging
import os
from typing import Any

import httpx

logger = logging.getLogger(__name__)

DEFAULT_HOST = "https://us.i.posthog.com"
SEND_TIMEOUT_SECONDS = 2.0

EVENT_SIGNUP = "signup"
EVENT_ACTIVATION = "activation"

# In-flight sends are capped; beyond this, new events are dropped so a slow or
# down PostHog can never pile up unbounded tasks.
MAX_PENDING = 256

_pending: set[asyncio.Task[None]] = set()
_client: httpx.AsyncClient | None = None


def _config() -> tuple[str, str] | None:
"""(api_key, host) from process env first, then Settings (``.env``)."""
from app.core.config import get_settings

settings = get_settings()
api_key = os.environ.get("POSTHOG_API_KEY", "").strip()
if not api_key:
api_key = settings.POSTHOG_API_KEY.get_secret_value().strip()
if not api_key:
return None
host = (
os.environ.get("POSTHOG_HOST", "").strip()
or settings.POSTHOG_HOST.strip()
or DEFAULT_HOST
)
if not host.lower().startswith("https://"):
# The identify payload carries the sponsor email; never send it in
# cleartext. A non-HTTPS host leaves tracking off.
return None
return api_key, host.rstrip("/")


def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=SEND_TIMEOUT_SECONDS)
return _client


def is_enabled() -> bool:
return _config() is not None


async def _post(host: str, payload: dict[str, Any]) -> None:
response = await _get_client().post(f"{host}/capture/", json=payload)
response.raise_for_status()


async def _send(host: str, payload: dict[str, Any]) -> None:
try:
await _post(host, payload)
except Exception as exc: # noqa: BLE001 - tracking must never raise
logger.debug(
"product_analytics_send_failed event=%s error=%s",
payload.get("event"),
type(exc).__name__,
)


def _enqueue(event: str, distinct_id: str, properties: dict[str, Any]) -> None:
try:
config = _config()
if config is None or not distinct_id:
return
if len(_pending) >= MAX_PENDING:
logger.debug("product_analytics_dropped event=%s reason=backlog", event)
return
api_key, host = config
payload = {
"api_key": api_key,
"event": event,
"distinct_id": distinct_id,
"properties": {**properties, "$lib": "agent-middleware-gateway"},
}
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return # no event loop (sync caller): drop rather than block
task = loop.create_task(_send(host, payload))
_pending.add(task)
task.add_done_callback(_pending.discard)
except Exception as exc: # noqa: BLE001 - tracking must never raise
logger.debug(
"product_analytics_enqueue_failed event=%s error=%s",
event,
type(exc).__name__,
)


def identify(distinct_id: str, *, email: str | None) -> None:
"""Attach the email person property to the account."""
props: dict[str, Any] = {}
if email:
props["$set"] = {"email": email}
_enqueue("$identify", distinct_id, props)


def capture(event: str, distinct_id: str, **properties: Any) -> None:
_enqueue(event, distinct_id, properties)


def track_signup(distinct_id: str, *, method: str, email: str | None = None) -> None:
"""identify (with email when known) then a ``signup`` event."""
identify(distinct_id, email=email)
capture(EVENT_SIGNUP, distinct_id, method=method)


def track_activation(distinct_id: str, *, key_id: str, wallet_id: str) -> None:
capture(EVENT_ACTIVATION, distinct_id, key_id=key_id, wallet_id=wallet_id)


async def drain_pending() -> None:
"""Await in-flight sends (tests and graceful shutdown)."""
if _pending:
await asyncio.gather(*list(_pending), return_exceptions=True)


async def shutdown() -> None:
"""Drain in-flight sends and close the shared client (app shutdown)."""
global _client
try:
await asyncio.wait_for(drain_pending(), timeout=SEND_TIMEOUT_SECONDS + 1)
except Exception as exc: # noqa: BLE001 - shutdown must not raise
logger.debug("product_analytics_drain_failed error=%s", type(exc).__name__)
client, _client = _client, None
if client is not None:
try:
await client.aclose()
except Exception as exc: # noqa: BLE001
logger.debug("product_analytics_close_failed error=%s", type(exc).__name__)
5 changes: 5 additions & 0 deletions gateway/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ class WalletModel(SQLModel, table=True):
daily_limit: Optional[Decimal] = Field(default=None, decimal_places=8)
daily_spent: Decimal = Field(default=Decimal("0"), decimal_places=8)
daily_reset_at: Optional[datetime] = Field(sa_type=NaiveUTCDateTime, default=None)
# Account-level product activation claim (root wallets only), set once by
# a conditional UPDATE on the first key use; see 055 migration.
product_activated_at: Optional[datetime] = Field(
sa_type=NaiveUTCDateTime, default=None
)

# Velocity monitoring (spend per hour for anomaly detection)
hourly_limit: Optional[Decimal] = Field(default=None, decimal_places=8)
Expand Down
4 changes: 4 additions & 0 deletions gateway/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,10 @@ async def periodic_cleanup():
logger.info("app_shutdown", phase="starting")
shutdown_start = time.monotonic()

from .core import product_analytics

await product_analytics.shutdown()

if cleanup_task:
cleanup_task.cancel()
try:
Expand Down
7 changes: 6 additions & 1 deletion gateway/app/routers/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

from ..core import product_analytics
from ..core.auth import AuthContext, get_auth_context, verify_api_key
from ..core.config import get_settings
from ..core.wallet_resolution import resolve_wallet_id
Expand Down Expand Up @@ -280,14 +281,18 @@ async def create_sponsor_wallet(
if request.initial_credits
else Decimal("0")
)
return await money.create_sponsor_wallet(
wallet = await money.create_sponsor_wallet(
sponsor_name=request.sponsor_name,
email=request.email,
initial_credits=initial,
currency=request.currency,
metadata=request.metadata,
require_kyc=request.require_kyc,
)
product_analytics.track_signup(
wallet.wallet_id, method="sponsor_wallet", email=request.email
)
return wallet


@router.post(
Expand Down
2 changes: 2 additions & 0 deletions gateway/app/routers/demo_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pydantic import BaseModel, Field, field_validator

from ..core.auth import AuthContext, get_auth_context
from ..core import product_analytics
from ..core.config import get_settings
from ..core.rate_limiter import _client_id
from ..services.demo_tenant import (
Expand Down Expand Up @@ -225,6 +226,7 @@ async def issue_demo_key(
key_id=minted["key_id"],
wallet_id=minted["wallet_id"],
)
product_analytics.track_signup(minted["sponsor_wallet_id"], method="demo_key")
base_url = settings.PUBLIC_URL.rstrip("/") if settings.PUBLIC_URL else ""
return DemoKeyIssueResponse(
api_key=minted["api_key"],
Expand Down
2 changes: 2 additions & 0 deletions gateway/app/routers/dev_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field

from ..core import product_analytics
from ..core.browser_origin import validate_browser_origin
from ..core.config import get_settings
from ..core.trust_mode import is_production_like_environment
Expand Down Expand Up @@ -171,6 +172,7 @@ async def self_provision_dev_key(
key_name=request.key_name,
)

product_analytics.track_signup(sponsor.wallet_id, method="dev_self_provision")
return SelfProvisionResponse(
sponsor_wallet_id=sponsor.wallet_id,
wallet_id=agent.wallet_id,
Expand Down
Loading
Loading