Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
17 changes: 17 additions & 0 deletions backend/database/webhook_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,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 @@ -900,3 +900,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 2034 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 @@ -427,6 +427,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 @@ -1054,7 +1101,7 @@


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

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

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

get_user_subscription_endpoint is 205 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 14260 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 @@ -11682,6 +11682,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 var 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 var components = URLComponents(string: client.baseURL + _path) else {
Expand Down Expand Up @@ -14232,5 +14256,5 @@
return try JSONDecoder().decode(OmiAnyCodable.self, from: data)
}

// Total: 379 Swift client methods generated.
// Total: 380 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 15475 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 @@ -1371,6 +1371,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 @@ -3840,6 +3851,7 @@
"DevApiKey": DevApiKey;
"DevApiKeyCreate": DevApiKeyCreate;
"DevApiKeyCreated": DevApiKeyCreated;
"DevWebhookHealthResponse": DevWebhookHealthResponse;
"DeveloperActionItem": DeveloperActionItem;
"DeveloperConversation": DeveloperConversation;
"DeveloperConversationActionItem": DeveloperConversationActionItem;
Expand Down Expand Up @@ -7024,6 +7036,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 @@ -13476,6 +13499,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 @@ -15430,4 +15472,4 @@
return _res.status === 204 ? (undefined as any) : await _res.json();
}

// Total: 379 client methods generated.
// Total: 380 client methods generated.
Loading
Loading