Skip to main content

Declarative Observability (AppHost)

OpenRegister's AppHost observability engine lets a Conduction app declare its health checks and Prometheus metrics as a JSON observability block in src/manifest.json. OpenRegister executes the descriptors and serves the responses through generic, reusable controllers — so an adopting app ships zero hand-written observability PHP while keeping its existing /apps/{appid}/api/health and /apps/{appid}/api/metrics URLs and the ADR-006 contract.

This is the implementation of ADR-040 (declarative observability). It specialises ADR-006 (the endpoint contract, now enforced centrally) and reuses the ADR-035 provider-alias discovery pattern for the escape hatch.

The manifest observability block

"observability": {
"health": {
"statusCodePolicy": "adr006", // "adr006" (default): 503 when any critical check fails, 200 otherwise
// "always200": always HTTP 200, status field carries ok|degraded|error
"cors": false, // true adds Access-Control-Allow-* headers (reverse-proxy probe contract)
"checks": [
{ "id": "database", "type": "database" },
{ "id": "filesystem", "type": "filesystem", "severity": "degraded" },
{ "id": "openregister", "type": "appEnabled", "app": "openregister", "severity": "critical" },
{ "id": "config", "type": "appConfig", "key": "token_set", "assert": "nonEmpty", "severity": "degraded" },
{ "id": "or", "type": "orAvailable" }
]
},
"metrics": [
// {app}_info and {app}_up are implicit — never declared.
{ "name": "cases_total", "type": "gauge", "help": "Cases by status", "cacheTtl": 30,
"source": { "kind": "objectCount", "register": "procest", "schema": "zaak", "groupBy": ["status"] } },
{ "name": "cases_overdue_total", "type": "gauge",
"source": { "kind": "objectCount", "schema": "zaak", "filter": { "deadline": { "lt": "now" } } } },
{ "name": "leads_value_total", "type": "gauge",
"source": { "kind": "objectSum", "schema": "lead", "field": "value", "groupBy": ["pipeline"] } },
{ "name": "widgets_total", "type": "gauge",
"source": { "kind": "tableCount", "table": "launchpad_widget_placements" } },
{ "name": "dashboards_total", "type": "gauge",
"source": { "kind": "tableCount", "table": "launchpad_dashboards",
"groupBy": ["type"], "labelDefaults": { "type": "personal" },
"labelMap": { "type": "kind" }, "filter": { "status": { "eq": "active" } } } },
{ "name": "pdf_generations_total", "type": "counter",
"source": { "kind": "appConfig", "key": "pdf_generations_total" } },
{ "name": "customer_bridge", "source": { "kind": "provider" } }
]
}

If the block is absent, the app still gets compliant endpoints: a database health check (plus orAvailable when the manifest declares OpenRegister registers) and the implicit {app}_info / {app}_up metrics.

Health check types (closed set)

typeParamsSemantics
databaseSELECT 1 via IDBConnection
filesystemwrite + unlink a temp file
appEnabledappIAppManager::isInstalled
appConfigkey, assert (present | nonEmpty)IAppConfig read
orAvailableresolve OpenRegister's ObjectService, non-null

Each check carries a severity: critical (default — failure drives status error and HTTP 503 under adr006) or degraded (failure drives status degraded, still HTTP 200).

Response shape:

{ "status": "ok|degraded|error", "app": "myapp", "version": "1.2.3",
"checks": { "database": "ok", "config": "failed: config key empty" } }

Check results are only ever ok or failed[: <generic message>]; exception messages are logged, never returned.

Metric source kinds (closed set + escape hatch)

kindParamsExecutes as
objectCountregister, schema (slugs), groupBy[], filterOpenRegister portable aggregation (no raw JSON SQL)
objectSumas above + field (numeric JSON field)OpenRegister aggregation SUM
tableCounttable, groupBy[], filter, labelDefaults, labelMapQueryBuilder COUNT / GROUP BY (aggregate-only)
appConfigkeyIAppConfig::getValueInt
providermerge the app's registered IMetricsProvider output
(implicit){app}_info, {app}_up

filter operators: eq, neq, lt, lte, gt, gte, like, with the server-side date tokens now and today.

cacheTtl (seconds, default 0): the engine memoises the rendered sample set per metric via the distributed cache (key = appId + metric name).

tableCount security

tableCount.table MUST match ^[a-z0-9_]+$ — the allowlist regex is enforced both at manifest-validation time and (defence in depth) inside the source. The table name is passed through the QueryBuilder table API (never string concatenation), all groupBy/filter columns are validated identifiers, and the engine only ever emits aggregate counts — never row data.

Engine-owned contract

  • The health endpoint is public (#[PublicPage] + #[NoCSRFRequired]); the metrics endpoint is admin-only. Leaf apps cannot drift this through the manifest.
  • The exposition format is Prometheus text 0.0.4 (# HELP / # TYPE, {app}_ prefix sanitised, label escaping), Content-Type: text/plain; version=0.0.4.
  • Unknown check types, source kinds, or filter operators are reported as manifest diagnostics and fall back to defaults — never a runtime 500.

Escape hatch

Anything that cannot be expressed by a descriptor goes through a provider:

  • OCA\OpenRegister\AppHost\IHealthCheckProvidercheck(): array of {id => {ok, severity?, message?}}.
  • OCA\OpenRegister\AppHost\IMetricsProvidermetrics(): MetricSample[].

Register the implementation on your app's container under the alias OCA\OpenRegister\AppHost\IMetricsProvider::{yourAppId} (the ADR-035 pattern); a {"kind":"provider"} metric descriptor then merges its output into the generic response.

Adopting in a leaf app

  1. Add the observability block to src/manifest.json.

  2. Point your existing routes at the generic controllers via container service aliases in your Application.php (URLs do not change):

    $context->registerServiceAlias(
    'OCA\\YourApp\\Controller\\HealthController',
    \OCA\OpenRegister\AppHost\Controller\GenericHealthController::class
    );
    $context->registerServiceAlias(
    'OCA\\YourApp\\Controller\\MetricsController',
    \OCA\OpenRegister\AppHost\Controller\GenericMetricsController::class
    );
  3. Delete the hand-written HealthController / MetricsController.

The controller's $appName (your app id) is what the engine uses to load your manifest, so the same OpenRegister engine serves every app correctly.

Migration table

Old hand-written patternDeclarative replacement
SELECT 1 in HealthController{"type":"database"}
temp-file write check{"type":"filesystem"}
isEnabledForUser check{"type":"appEnabled","app":"..."}
OR DI lookup health check{"type":"orAvailable"}
PHP-side object JSON aggregation{"kind":"objectCount"/"objectSum", ...}
LIKE '%pattern%' schema matchschema slug in the descriptor
own-table COUNT queries{"kind":"tableCount","table":"..."}
appconfig counter{"kind":"appConfig","key":"..."}
JSON metrics outputengine renders Prometheus 0.0.4
public metrics / auth'd health driftengine-owned auth posture

OpenRegister runs on its own engine (the living example)

OpenRegister is the first adopter of the AppHost observability engine — it dogfoods its own engine. The ~520 lines of hand-written HealthController + MetricsController have been deleted; OR's /apps/openregister/api/health and /apps/openregister/api/metrics routes are aliased at AppHost\Controller\GenericHealth#index / GenericMetrics#index in appinfo/routes.php, and the descriptors live in the observability block of src/manifest.json. That block is the canonical, working reference for every other Conduction app adopting the engine: two health checks (database critical, filesystem degraded) and nine metric descriptors (registers/schemas/objects counts, the four audit-trail CRUD counters via tableCount + action filter, the search-request counter, and the webhook-delivery counter).

The endpoint contract is unchanged from the deleted controllers — the same metric names, types and label keys, verified by byte-comparing the Prometheus output and the health JSON before vs after adoption on a seeded instance. Three intentional improvements ride along with the engine:

  • the health JSON envelope now carries an app field ({status, app, version, checks});
  • the implicit {app}_info gauge gains a nextcloud_version label alongside version + php_version;
  • /api/health is now public (#[PublicPage], ADR-006 anonymous health) instead of login-gated.

One label-semantics note for objects_total: the engine's tableCount groupBy: ["register","schema"] emits the numeric register/schema ids as label values (the bespoke controller joined to the title columns). Use the objectCount source kind if you need slug/title labels in your own app.