Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
"backend/routers/developer.py": 2184,
"backend/routers/mcp_sse.py": 1857,
"backend/routers/sync.py": 2024,
"backend/routers/users.py": 2046
"backend/routers/users.py": 2093
},
"raise_justifications": {
"backend/routers/chat.py": "PTT stereo rejection keeps the serving STT provider boundary explicit in the established chat admission owner.",
"backend/routers/users.py": "GET /v1/users/subscription serializes the new mobile plus/max plans as `unlimited` for clients whose plan enum predates them, so day-one buyers read as paid instead of Free (mirrors the existing operator remap); real limits/grandfather are computed from the true plan first.",
"backend/routers/users.py": "GET /v1/users/developer/webhook/{wtype}/health lives with the other developer-webhook routes it shares auth, uid scoping and response serialization with; a separate module would split one health read away from the handlers that own that surface.",
"backend/routers/sync.py": "Fresh Sync admission and final Cloud Tasks ledger recovery retain their coupled task, lock, ledger, and staged-blob lifecycle in the existing ingestion owner rather than a new module."
},
"threshold": 1500
Expand Down
17 changes: 17 additions & 0 deletions backend/database/webhook_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,3 +428,20 @@ def record_dev_webhook_success(uid: str, wtype: object):
r.expire(key, _HEALTH_TTL)
except Exception as e:
logger.warning(f'record_dev_webhook_success redis error uid={uid} type={wtype}: {e}')


def get_dev_webhook_health(uid: str, wtype) -> Optional[dict]:
"""Health telemetry for a user's developer webhook of a given type, or None when nothing recorded.

Fail-open: any Redis error returns None so a degraded Redis never breaks the read. The key and the
type stringify mirror record_dev_webhook_success/record_dev_webhook_failure so reads and writes align.
"""
try:
wtype_str = wtype.value if hasattr(wtype, 'value') else str(wtype)
key = f'dev_webhook_health:{uid}:{wtype_str}'
data = r.hgetall(key)
if not data:
return None
return {k.decode(): v.decode() for k, v in data.items()}
except Exception:
return None
23 changes: 23 additions & 0 deletions backend/route_policy_manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1079,3 +1079,26 @@ routes:
deprecation:
state: active
owner: backend
- route_type: http
method: GET
path: /v1/users/developer/webhook/{wtype}/health
policy:
review_status: reviewed
auth:
mechanisms:
- firebase_id_token
placement: dependency
scopes: []
byok: not_applicable
rate_limit:
policy_name: none
key_subject: none
enforcement: none
placement: none
timeout_class: default_method
surface: first_party_app
visibility: first_party
data_domain: metrics
deprecation:
state: active
owner: backend
49 changes: 48 additions & 1 deletion backend/routers/users.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from __future__ import annotations

Check warning on line 1 in backend/routers/users.py

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

backend/routers/users.py is 2093 lines; consider splitting files over 800 lines.

Check warning on line 1 in backend/routers/users.py

View workflow job for this annotation

GitHub Actions / Desktop Swift Build & Tests

Large changed file

backend/routers/users.py is 2093 lines; consider splitting files over 800 lines.

import re
import uuid
Expand Down Expand Up @@ -26,7 +26,7 @@
from services.users.data_export import iter_user_data_export
from services.users.account_deletion import background_wipe_user_data, start_account_deletion
from database.app_review_config import should_hide_subscription_ui
from database.webhook_health import record_dev_webhook_success
from database.webhook_health import record_dev_webhook_success, get_dev_webhook_health
from database.conversations import get_in_progress_conversation, get_conversation
from database.redis_db import (
cache_user_geolocation,
Expand Down Expand Up @@ -481,6 +481,53 @@
return {'status': 'ok'}


class DevWebhookHealthResponse(BaseModel):
type: str
has_data: bool
failure_count: int = 0
last_success_at: Optional[int] = None
last_failure_at: Optional[int] = None
last_status: Optional[int] = None
last_error: Optional[str] = None
disabled: bool = False


@router.get('/v1/users/developer/webhook/{wtype}/health', tags=['v1'], response_model=DevWebhookHealthResponse)
def get_user_webhook_health_endpoint(wtype: WebhookType, uid: str = Depends(auth.get_current_user_uid)):
"""Delivery health for a developer webhook: failure count, last success/failure timestamps
(unix seconds), last HTTP status, last error, and whether it was auto-disabled after sustained
failures. Fields are zeroed / false / null when nothing has been recorded yet."""

def _as_int(v):
try:
return int(v)
except (TypeError, ValueError):
return None

health = get_dev_webhook_health(uid, wtype)
if not health:
return {
'type': wtype.value,
'has_data': False,
'failure_count': 0,
'last_success_at': None,
'last_failure_at': None,
'last_status': None,
'last_error': None,
'disabled': False,
}
return {
'type': wtype.value,
'has_data': True,
'failure_count': _as_int(health.get('failure_count')) or 0,
'last_success_at': _as_int(health.get('last_success_at')),
'last_failure_at': _as_int(health.get('last_failure_at')),
'last_status': _as_int(health.get('last_status')),
'last_error': health.get('last_error') or None,
'disabled': health.get('disabled') == '1',
}


@router.get('/v1/users/developer/webhooks/status', tags=['v1'], response_model=UserWebhooksStatusResponse)
def get_user_webhooks_status(uid: str = Depends(auth.get_current_user_uid)):
# This only happens the first time because the user_webhook_status_db function will return None for existing users
Expand Down Expand Up @@ -1108,7 +1155,7 @@


@router.get('/v1/users/me/subscription', tags=['v1'], response_model=UserSubscriptionResponse)
def get_user_subscription_endpoint(

Check warning on line 1158 in backend/routers/users.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

get_user_subscription_endpoint is 210 lines; consider extracting focused helpers over 150 lines.

Check warning on line 1158 in backend/routers/users.py

View workflow job for this annotation

GitHub Actions / Desktop Swift Build & Tests

Long function

get_user_subscription_endpoint is 210 lines; consider extracting focused helpers over 150 lines.
# Keep reachable even when BYOK fingerprints drift — broken-BYOK users
# must still see their plan so they can recover.
uid: str = Depends(auth.get_current_user_uid_no_byok_validation),
Expand Down
108 changes: 108 additions & 0 deletions backend/tests/unit/test_dev_webhook_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Unit tests for GET /v1/users/developer/webhook/{wtype}/health.

The db helper (get_dev_webhook_health) is verified directly against a patched Redis
proxy. The router endpoint's response mapping is verified in CI, where routers.users'
heavy STT imports resolve; locally those deps are absent, so the endpoint cases skip
while the db-helper cases still run. Uses the sanctioned seams (import + patch.object,
no sys.modules mutation).
"""

import os

os.environ.setdefault(
"ENCRYPTION_SECRET",
"omi_ZwB2ZNqB2HHpMK6wStk7sTpavJiPTFg7gXUHnc4tFABPU6pZ2c2DKgehtfgi4RZv",
)
os.environ.setdefault("OPENAI_API_KEY", "test-openai-key-not-real")

from unittest.mock import patch

import pytest

import database.webhook_health as wh

try:
from routers import users as users_router

_USERS_IMPORTABLE = True
except Exception: # heavy STT deps unavailable locally; present in CI
users_router = None
_USERS_IMPORTABLE = False


class _Wtype:
"""Stand-in for a WebhookType enum member (only .value is used)."""

def __init__(self, value):
self.value = value


# ---------------------------------------------------------------------------
# db helper: get_dev_webhook_health
# ---------------------------------------------------------------------------
def test_db_health_none_when_no_data():
with patch.object(wh, "r") as r:
r.hgetall.return_value = {}
assert wh.get_dev_webhook_health("u1", _Wtype("audio_bytes")) is None


def test_db_health_decodes_and_uses_value_key():
with patch.object(wh, "r") as r:
r.hgetall.return_value = {b"failure_count": b"3", b"disabled": b"1", b"last_error": b"boom"}
out = wh.get_dev_webhook_health("u1", _Wtype("audio_bytes"))
assert out == {"failure_count": "3", "disabled": "1", "last_error": "boom"}
assert r.hgetall.call_args[0][0] == "dev_webhook_health:u1:audio_bytes"


def test_db_health_fail_open_on_redis_error():
with patch.object(wh, "r") as r:
r.hgetall.side_effect = RuntimeError("redis down")
assert wh.get_dev_webhook_health("u1", _Wtype("audio_bytes")) is None


def test_db_health_stringifies_non_enum_type():
with patch.object(wh, "r") as r:
r.hgetall.return_value = {b"failure_count": b"0"}
wh.get_dev_webhook_health("u1", "memory_created")
assert r.hgetall.call_args[0][0] == "dev_webhook_health:u1:memory_created"


# ---------------------------------------------------------------------------
# router endpoint mapping (runs in CI where routers.users imports)
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not _USERS_IMPORTABLE, reason="routers.users heavy deps unavailable locally")
def test_endpoint_has_data_false_when_absent():
with patch.object(users_router, "get_dev_webhook_health", return_value=None):
resp = users_router.get_user_webhook_health_endpoint(wtype=_Wtype("audio_bytes"), uid="u1")
assert resp == {
"type": "audio_bytes",
"has_data": False,
"failure_count": 0,
"last_success_at": None,
"last_failure_at": None,
"last_status": None,
"last_error": None,
"disabled": False,
}


@pytest.mark.skipif(not _USERS_IMPORTABLE, reason="routers.users heavy deps unavailable locally")
def test_endpoint_maps_recorded_fields():
health = {
"failure_count": "4",
"last_success_at": "1700000000",
"last_failure_at": "", # reset -> None
"last_status": "500",
"last_error": "", # reset -> None
"disabled": "1",
}
with patch.object(users_router, "get_dev_webhook_health", return_value=health):
resp = users_router.get_user_webhook_health_endpoint(wtype=_Wtype("audio_bytes"), uid="u1")
assert resp["type"] == "audio_bytes"
assert resp["has_data"] is True
assert resp["failure_count"] == 4
assert resp["last_success_at"] == 1700000000
assert resp["last_failure_at"] is None
assert resp["last_status"] == 500
assert resp["last_error"] is None
assert resp["disabled"] is True
26 changes: 25 additions & 1 deletion desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// GENERATED CODE - DO NOT EDIT.

Check warning on line 1 in desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift is 14311 lines; consider splitting files over 800 lines.

Check warning on line 1 in desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift

View workflow job for this annotation

GitHub Actions / Desktop Swift Build & Tests

Large changed file

desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift is 14311 lines; consider splitting files over 800 lines.
// Generated by backend/scripts/generate_swift_openapi_types.py from docs/api-reference/app-client-openapi.json
// Swift wire DTOs for the desktop app's backend REST surface. Domain models
// (ServerConversation, ServerMemory, Goal, ActionItem) adapt from these types;
Expand Down Expand Up @@ -11733,6 +11733,30 @@
return try JSONDecoder().decode(OmiAnyCodable.self, from: data)
}

public static func getUserWebhookHealthEndpointV1UsersDeveloperWebhookWtypeHealthGet(client: OmiApiClient, wtype: String, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> OmiAnyCodable {
let _path = "/v1/users/developer/webhook/\(wtype)/health"
guard let components = URLComponents(string: client.baseURL + _path) else {
throw OmiApiError.invalidURL
}
guard let url = components.url else { throw OmiApiError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = "GET"
for (name, value) in client.headers { req.setValue(value, forHTTPHeaderField: name) }
if let token = client.token {
req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
}
if let authorization { req.setValue(String(authorization), forHTTPHeaderField: "authorization") }
if let xAppPlatform { req.setValue(String(xAppPlatform), forHTTPHeaderField: "X-App-Platform") }
if let xDeviceIdHash { req.setValue(String(xDeviceIdHash), forHTTPHeaderField: "X-Device-Id-Hash") }
if let xAppVersion { req.setValue(String(xAppVersion), forHTTPHeaderField: "X-App-Version") }
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse else { throw OmiApiError.invalidURL }
guard (200..<300).contains(http.statusCode) else {
throw OmiApiError.httpError(status: http.statusCode, data: data)
}
return try JSONDecoder().decode(OmiAnyCodable.self, from: data)
}

public static func getUserWebhooksStatusV1UsersDeveloperWebhooksStatusGet(client: OmiApiClient, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> OmiAnyCodable {
let _path = "/v1/users/developer/webhooks/status"
guard let components = URLComponents(string: client.baseURL + _path) else {
Expand Down Expand Up @@ -14283,5 +14307,5 @@
return try JSONDecoder().decode(OmiAnyCodable.self, from: data)
}

// Total: 381 Swift client methods generated.
// Total: 382 Swift client methods generated.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"change": "Adds desktop API client support for developer webhook health."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"change": "Add GET /v1/users/developer/webhook/{wtype}/health to the generated Omi API client."
}
44 changes: 43 additions & 1 deletion desktop/windows/src/renderer/src/lib/omiApi.generated.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// GENERATED CODE - DO NOT EDIT.

Check warning on line 1 in desktop/windows/src/renderer/src/lib/omiApi.generated.ts

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

desktop/windows/src/renderer/src/lib/omiApi.generated.ts is 15584 lines; consider splitting files over 800 lines.

Check warning on line 1 in desktop/windows/src/renderer/src/lib/omiApi.generated.ts

View workflow job for this annotation

GitHub Actions / Desktop Swift Build & Tests

Large changed file

desktop/windows/src/renderer/src/lib/omiApi.generated.ts is 15584 lines; consider splitting files over 800 lines.
/* eslint-disable prettier/prettier, @typescript-eslint/no-explicit-any */
// Generated by backend/scripts/generate_ts_openapi_types.py from docs/api-reference/app-client-openapi.json.

Expand Down Expand Up @@ -1386,6 +1386,17 @@
scopes?: Array<string> | null;
}

export interface DevWebhookHealthResponse {
disabled?: boolean;
failure_count?: number;
has_data: boolean;
last_error?: string | null;
last_failure_at?: number | null;
last_status?: number | null;
last_success_at?: number | null;
type: string;
}

export interface DeveloperActionItem {
completed: boolean;
completed_at?: string | null;
Expand Down Expand Up @@ -3882,6 +3893,7 @@
"DevApiKey": DevApiKey;
"DevApiKeyCreate": DevApiKeyCreate;
"DevApiKeyCreated": DevApiKeyCreated;
"DevWebhookHealthResponse": DevWebhookHealthResponse;
"DeveloperActionItem": DeveloperActionItem;
"DeveloperConversation": DeveloperConversation;
"DeveloperConversationActionItem": DeveloperConversationActionItem;
Expand Down Expand Up @@ -7091,6 +7103,17 @@
};
};
};
"/v1/users/developer/webhook/{wtype}/health": {
get: {
operationId: "get_user_webhook_health_endpoint_v1_users_developer_webhook__wtype__health_get";
responses: {
"200": DevWebhookHealthResponse;
"401": void;
"404": void;
"422": HTTPValidationError;
};
};
};
"/v1/users/developer/webhooks/status": {
get: {
operationId: "get_user_webhooks_status_v1_users_developer_webhooks_status_get";
Expand Down Expand Up @@ -13585,6 +13608,25 @@
return _res.status === 204 ? (undefined as any) : await _res.json();
}

export async function get_user_webhook_health_endpoint_v1_users_developer_webhook__wtype__health_get(path: { wtype: WebhookType }, header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise<DevWebhookHealthResponse> {
const _base = init?.baseURL ?? "";
const _path = `/v1/users/developer/webhook/${path.wtype}/health`;
const _search = "";
const _res = await fetch(`${_base}${_path}${_search}`, {
method: "GET",
headers: {
...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}),
...init?.headers,
...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}),
...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}),
...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}),
...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}),
},
});
if (!_res.ok) throw new OmiApiError(_res.status, _res);
return _res.status === 204 ? (undefined as any) : await _res.json();
}

export async function get_user_webhooks_status_v1_users_developer_webhooks_status_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise<UserWebhooksStatusResponse> {
const _base = init?.baseURL ?? "";
const _path = `/v1/users/developer/webhooks/status`;
Expand Down Expand Up @@ -15539,4 +15581,4 @@
return _res.status === 204 ? (undefined as any) : await _res.json();
}

// Total: 381 client methods generated.
// Total: 382 client methods generated.
Loading
Loading