Skip to content
Open
Show file tree
Hide file tree
Changes from all 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