title: Observability Guide for FraiseQL description: - Observability fundamentals (logs, metrics, traces - the three pillars) keywords: ["debugging", "implementation", "best-practices", "deployment", "tutorial"] tags: ["documentation", "reference"]
Observability Guide for FraiseQL¶
Status: ✅ Production Ready Audience: DevOps, SREs, Architects Reading Time: 15-20 minutes
Prerequisites¶
Required Knowledge¶
- Observability fundamentals (logs, metrics, traces - the three pillars)
- Structured logging and JSON formats
- Time-series metrics and Prometheus concepts
- Distributed tracing and span concepts
- Audit logging and compliance requirements
- Multi-tenancy data isolation patterns (PostgreSQL Row-Level Security)
Required Software¶
- FraiseQL v1 (Python 3.13+, served as a FastAPI app)
- PostgreSQL 14+
- Prometheus (for metrics collection)
- Grafana (for visualization) or alternative dashboarding tool
- Jaeger, Zipkin, or any OTLP-compatible backend (for distributed tracing)
- Log aggregation tool (ELK, Splunk, DataDog, New Relic, or similar)
- curl or a GraphQL client for testing
Required Infrastructure¶
- A running FraiseQL FastAPI application (
uvicorn app:app) - PostgreSQL database
- Prometheus scrape-compatible endpoint (FraiseQL exposes
/metrics) - Log collection infrastructure (syslog, vector, fluentd, etc.)
- Trace backend (Jaeger collector, Zipkin server, or OTLP collector)
- Metrics storage (Prometheus or similar time-series database)
- Grafana or visualization tool
- Network connectivity between all components
Optional but Recommended¶
- Kubernetes monitoring (Prometheus Operator)
- Alert manager for anomaly detection
- Custom Grafana dashboards/templates
- Distributed tracing sampling strategies
- Log retention and archival policies
- Metrics correlation tools
Time Estimate: 1-3 hours for basic setup, 4-8 hours for production configuration with alerting
1. Overview¶
Observability in FraiseQL means understanding what's happening in your system through three pillars:
- Logs — Detailed records of what occurred (queries, mutations, errors, decisions)
- Metrics — Aggregated measurements (rates, latencies, counts)
- Traces — Request flows from entry to exit with timing
FraiseQL exposes observability at two levels:
- Application level — The FastAPI app emits Prometheus metrics (
/metrics), OpenTelemetry traces, health checks, and structured Python logs. These are wired in via the helpers infraiseql.monitoringandfraiseql.tracing. - Database level — Because reads and writes run through PostgreSQL views (
v_/tv_) and functions (fn_), the database is a rich source of truth:pg_stat_statements,pg_stat_activity, and any audit tables you maintain in your own schema.
This combination enables:
- Performance analysis — Query execution patterns visible in metrics, traces, and PostgreSQL stats
- Deterministic debugging — Mutation outcomes returned as structured success/error results
- Compliance audits — Application-defined audit tables capture user/tenant context
- Real-time alerts — Prometheus alerting rules over the exported metrics
- Multi-tenant isolation — All observations scoped by tenant via Row-Level Security
2. Application Metrics (Prometheus)¶
FraiseQL ships first-class Prometheus integration in fraiseql.monitoring. Call setup_metrics
on the FastAPI app returned by create_fraiseql_app; it registers a /metrics endpoint and an
HTTP middleware that records request counts, durations, and error rates.
from fraiseql.fastapi import create_fraiseql_app
from fraiseql.monitoring import setup_metrics, MetricsConfig
app = create_fraiseql_app(
database_url="postgresql://localhost/mydb",
types=[User],
queries=[users, user],
mutations=[create_user],
production=True,
)
# Adds /metrics, the metrics middleware, and a global FraiseQLMetrics instance
metrics = setup_metrics(
app,
MetricsConfig(
enabled=True,
namespace="fraiseql", # prefix for every metric name
metrics_path="/metrics", # Prometheus scrape path
exclude_paths={"/metrics", "/health", "/ready", "/startup"},
labels={"service": "orders-api", "env": "production"},
),
)
MetricsConfig (from fraiseql.monitoring) accepts:
| Field | Purpose | Default |
|---|---|---|
enabled |
Toggle collection on/off | True |
namespace |
Prefix applied to every metric name | "fraiseql" |
metrics_path |
URL path Prometheus scrapes | "/metrics" |
buckets |
Histogram bucket boundaries for latency metrics | sensible default set |
exclude_paths |
Paths skipped by the HTTP metrics middleware | health/metrics paths |
labels |
Extra labels applied to all metrics | {} |
Install the optional dependency to enable real metrics (FraiseQL degrades to no-op placeholders when it is absent):
uv pip install prometheus-client
2.1 What gets exported¶
The middleware and the FraiseQLMetrics collector track, among others:
fraiseql_http_requests_total{method,endpoint,status}— request countsfraiseql_http_request_duration_seconds{method,endpoint}— request latency histogram- GraphQL query/mutation counts and durations
- Error counts by type and operation
Scrape config for Prometheus:
scrape_configs:
- job_name: fraiseql
metrics_path: /metrics
static_configs:
- targets: ["fraiseql-app:8000"]
2.2 Recording custom metrics¶
Wrap a resolver or any callable with with_metrics to record execution time and success/failure
against the global metrics instance:
from fraiseql.monitoring import with_metrics
@with_metrics("query")
async def expensive_report(info) -> Report:
db = info.context["db"]
return await db.find_one("v_report", id=info.variable_values["id"])
You can also reach the live collector directly with get_metrics() to record bespoke values.
3. Distributed Tracing (OpenTelemetry)¶
FraiseQL provides OpenTelemetry tracing in fraiseql.tracing. Call setup_tracing on the app to
add the tracing middleware; it automatically instruments psycopg so PostgreSQL queries appear as
spans, and exports to an OTLP, Jaeger, or Zipkin backend.
from fraiseql.tracing import setup_tracing, TracingConfig
setup_tracing(
app,
TracingConfig(
enabled=True,
service_name="orders-api",
service_version="1.4.0",
deployment_environment="production",
sample_rate=0.1, # 10% sampling
export_format="otlp", # "otlp" | "jaeger" | "zipkin"
export_endpoint="http://otel-collector:4317",
propagate_traces=True, # W3C trace-context propagation
exclude_paths={"/health", "/ready", "/metrics", "/docs", "/openapi.json"},
),
)
Install the OpenTelemetry extras you need (FraiseQL no-ops cleanly if they are missing):
uv pip install opentelemetry-sdk opentelemetry-exporter-otlp \
opentelemetry-instrumentation-psycopg
3.1 Span structure¶
With tracing enabled you get spans for:
- The inbound HTTP request (method, route, status)
- The GraphQL operation (operation type and name)
- Each PostgreSQL statement (via the psycopg instrumentor)
Helper utilities trace_graphql_operation and trace_database_query (also exported from
fraiseql.tracing) let you add custom spans around your own logic, and get_tracer() returns the
active FraiseQLTracer.
3.2 Correlating traces with logs¶
Because trace and span IDs are propagated via W3C trace-context, include them in your structured log lines (see section 5) so a log entry can be pivoted to its trace in Jaeger/Zipkin and back.
4. Health Checks¶
FraiseQL exposes a composable HealthCheck runner plus ready-made checks in
fraiseql.monitoring. Register the checks you care about and serve the aggregate result from a
FastAPI route for Kubernetes liveness/readiness probes.
from fraiseql.monitoring import (
HealthCheck,
check_database,
check_pool_stats,
check_query_stats,
)
health = HealthCheck()
health.add_check("database", check_database)
health.add_check("pool", check_pool_stats)
health.add_check("query_stats", check_query_stats)
@app.get("/health")
async def healthz():
result = await health.run_checks()
return result
Each check returns a CheckResult with a HealthStatus; the overall status degrades to unhealthy
if any check fails, and exceptions are caught and reported rather than crashing the probe.
5. Logging Patterns¶
FraiseQL uses the standard Python logging module. Configure log level and format with
logging.basicConfig(...) (or your aggregator's handler) when you start the app — there is no
separate logging config file. Run the app with uvicorn app:app and your logging configuration
applies to FraiseQL's loggers (which live under the fraiseql namespace).
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
# Turn up FraiseQL detail selectively
logging.getLogger("fraiseql").setLevel(logging.INFO)
logging.getLogger("fraiseql.sql").setLevel(logging.DEBUG) # log generated SQL while debugging
5.1 Structured logging¶
For machine-parseable logs, attach a JSON formatter (for example python-json-logger) and include
correlation fields. A typical structured log line:
{
"timestamp": "2026-01-11T15:00:00.123456Z",
"level": "INFO",
"message": "User created successfully",
"service": "orders-api",
"component": "mutation",
"request_id": "req_550e8400-e29b-41d4-a716-446655440000",
"trace_id": "trace_550e8400...",
"user_id": "user_550e8400-e29b-41d4-a716-446655440001",
"tenant_id": "org_550e8400-e29b-41d4-a716-446655440002",
"entity_type": "User",
"entity_id": "550e8400-e29b-41d4-a716-446655440003",
"status": "new",
"duration_ms": 245
}
5.2 Log levels¶
| Level | Purpose | Examples |
|---|---|---|
| ERROR | Unexpected failures | Mutation failed, database connection lost, authorization denied |
| WARN | Expected but notable | Validation failure, no-op, conflict, rate limit |
| INFO | Normal operations | Mutation completed, query executed, auth check passed |
| DEBUG | Development troubleshooting | Generated SQL, authorization decision, field projection |
5.3 Error tracking¶
fraiseql.monitoring includes a PostgreSQL-native error tracker (a Sentry-style replacement) that
persists captured exceptions to your database, and a notification system (EmailChannel,
SlackChannel, WebhookChannel) to alert on them.
from fraiseql.monitoring import init_error_tracker, get_error_tracker
tracker = init_error_tracker(db_pool, environment="production")
try:
await risky_operation()
except Exception as exc:
await tracker.capture_exception(exc, context={"request_id": request_id})
6. Request Tracing & Correlation¶
Every request should carry a request ID / correlation ID that flows through GraphQL execution
and PostgreSQL calls. Generate or read it in your context_getter, store it in info.context, and
thread it into both your log lines and your audit writes.
import uuid
async def context_getter(request):
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
return {
"request_id": request_id,
"tenant_id": request.headers.get("X-Tenant-ID"),
}
app = create_fraiseql_app(
database_url="postgresql://localhost/mydb",
types=[User],
queries=[users],
context_getter=context_getter,
)
Inside a fn_ function you can persist the correlation ID alongside the write so audit rows are
traceable back to the originating request:
CREATE OR REPLACE FUNCTION fn_create_user(
input_request_id UUID,
input_user_id UUID,
input_email TEXT
)
RETURNS JSONB
LANGUAGE plpgsql
AS $$
BEGIN
-- ... perform the write ...
INSERT INTO app.tb_audit_log (request_id, user_id, action, object_type)
VALUES (input_request_id, input_user_id, 'create', 'User');
RETURN jsonb_build_object('success', true);
END;
$$;
Trace context fields to carry¶
| Field | Purpose | Example |
|---|---|---|
request_id |
Link all operations in a request | req_550e8400... |
user_id |
Who initiated the request | uuid |
tenant_id |
Which organization | uuid |
session_id |
User session | sess_abc123 |
trace_id |
Distributed tracing | trace_550e8400... |
span_id |
Operation within trace | span_001 |
7. Database Observability (PostgreSQL)¶
Because all reads and writes run through PostgreSQL, the database's own statistics views are a
core part of FraiseQL observability. Enable pg_stat_statements and query the standard views.
7.1 Most expensive statements¶
-- pg_stat_statements: most expensive queries
SELECT
calls,
total_exec_time,
mean_exec_time,
query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
7.2 Connection pool and active queries¶
-- Connection state distribution
SELECT state, COUNT(*) AS count
FROM pg_stat_activity
GROUP BY state;
-- Active (non-idle) queries and their durations
SELECT
pid,
usename,
application_name,
state,
EXTRACT(EPOCH FROM (NOW() - state_change)) AS duration_sec,
query
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY state_change ASC;
7.3 Table access patterns and sizes¶
-- Sequential vs index scans per table
SELECT
schemaname,
relname AS tablename,
seq_scan,
seq_tup_read,
idx_scan,
idx_tup_fetch
FROM pg_stat_user_tables
ORDER BY seq_scan DESC;
-- Table / index sizes
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC;
7.4 Inspecting query plans¶
-- Analyze a read view's execution plan
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM v_user WHERE id = $1;
-- Look for:
-- - Sequential scans that should be index scans
-- - High costs (optimization opportunity)
-- - Buffer hits (cache effectiveness)
8. Query & Mutation Observability¶
8.1 Slow query detection¶
Use the application metrics histogram (section 2) to find slow GraphQL operations, and confirm at
the SQL layer with pg_stat_statements and EXPLAIN ANALYZE against the underlying v_/tv_
view. If you maintain your own query-timing table, you can aggregate percentiles:
-- Aggregate your application-level query timing table
SELECT
query_name,
COUNT(*) AS count,
AVG(execution_time_ms) AS avg_ms,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY execution_time_ms) AS p95_ms,
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY execution_time_ms) AS p99_ms
FROM app.query_log
WHERE logged_at > NOW() - INTERVAL '24 hours'
GROUP BY query_name
HAVING AVG(execution_time_ms) > 50
ORDER BY avg_ms DESC;
8.2 N+1 detection¶
FraiseQL prevents N+1 access through view composition (nest related data inside the data
JSONB of a v_/tv_ view) and @fraiseql.dataloader_field for batched field resolution. If a
trace (section 3) shows the same query repeated per parent row, switch to one of those patterns.
8.3 Cache hit/miss visibility¶
FraiseQL's PostgreSQL-backed result cache lives in fraiseql.caching (PostgresCache,
ResultCache, CachedRepository, CacheStats, cached_query, cascade-invalidation rules).
CacheStats exposes hit/miss counts you can surface as metrics or a dashboard panel:
from fraiseql.caching import ResultCache, CacheStats
# After wiring a ResultCache / CachedRepository, read its stats
stats: CacheStats = result_cache.stats
hit_rate = stats.hits / max(stats.hits + stats.misses, 1)
9. Metrics & Telemetry from the Database¶
You can also derive business and operational metrics directly from PostgreSQL using runtime
auto-aggregation in your v_/tv_ views or ad-hoc SQL. These complement the application metrics.
9.1 Request/mutation throughput¶
If you keep an application audit table (for example app.tb_audit_log), bucket it by time:
-- Mutations per minute over the last 24 hours
SELECT
DATE_TRUNC('minute', created_at) AS minute,
COUNT(*) AS mutations,
ROUND(COUNT(*) / 60.0, 2) AS mutations_per_sec
FROM app.tb_audit_log
WHERE created_at > NOW() - INTERVAL '24 hours'
GROUP BY minute
ORDER BY minute DESC;
9.2 Error rates¶
-- Failure rate from your audit table
SELECT
ROUND(
100.0 * COUNT(*) FILTER (WHERE status LIKE 'failed:%' OR status IN ('not_found', 'forbidden'))
/ COUNT(*),
2
) AS error_rate_pct
FROM app.tb_audit_log
WHERE created_at > NOW() - INTERVAL '24 hours';
9.3 Business metrics¶
-- New entities per day
SELECT
DATE(created_at) AS date,
object_type,
COUNT(*) AS new_entities
FROM app.tb_audit_log
WHERE created_at > NOW() - INTERVAL '30 days'
AND action = 'create'
GROUP BY DATE(created_at), object_type
ORDER BY date DESC;
These queries assume an audit table you define in your own schema. FraiseQL does not impose a specific audit schema; model it to suit your compliance needs and query it like any other table.
10. Monitoring & Alerting¶
10.1 Key metrics to monitor¶
| Metric | Threshold | Action |
|---|---|---|
| GraphQL error rate | > 5% | Page on-call |
| Query p95 latency | > 100ms | Investigate slow queries |
| Database connection pool | > 80% | Add connections or optimize |
| Authorization denials | > 1% of requests | Review auth rules |
10.2 Prometheus alert rules¶
Alert directly on the exported Prometheus metrics rather than polling SQL:
groups:
- name: fraiseql
rules:
- alert: HighErrorRate
expr: |
sum(rate(fraiseql_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(fraiseql_http_requests_total[5m])) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "FraiseQL 5xx error rate above 5%"
- alert: HighP95Latency
expr: |
histogram_quantile(
0.95,
sum(rate(fraiseql_http_request_duration_seconds_bucket[5m])) by (le)
) > 0.1
for: 10m
labels:
severity: warning
annotations:
summary: "FraiseQL p95 latency above 100ms"
10.3 Slow query alerting at the database¶
-- Find statements whose mean execution time exceeds 1 second
SELECT calls, mean_exec_time, query
FROM pg_stat_statements
WHERE mean_exec_time > 1000
ORDER BY mean_exec_time DESC;
11. Debugging Workflows¶
11.1 Debugging failed mutations¶
In v1 a @fraiseql.mutation resolver returns a typed success or error result built from the
JSONB its fn_ function returns. To debug a failure:
- Reproduce the mutation and capture the returned error result (message + code).
- Inspect the
fn_function's logic and any constraint it violated. - If you maintain an audit table, look up the row by
request_idto see context.
-- Find the audit row for a failed write
SELECT *
FROM app.tb_audit_log
WHERE object_id = $1
AND status LIKE 'failed:%'
ORDER BY created_at DESC
LIMIT 1;
11.2 Debugging slow queries¶
- Identify the slow operation from metrics or a trace.
- Run
EXPLAIN (ANALYZE, BUFFERS)on the underlyingv_/tv_view. - Verify indexes exist on the columns the view filters/joins on.
- Check for N+1 patterns (section 8.2) and apply view composition or
@fraiseql.dataloader_field.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM v_user WHERE id = $1;
11.3 Debugging authorization failures¶
- Confirm the auth context (
user_id, roles,tenant_id) reachedinfo.context. - Review the
Authorizerpassed tocreate_fraiseql_app(authorizer=...)or the@fraiseql.query(authorizer=...)decision. - For multi-tenant data, verify the Row-Level Security policy and that the session GUC
(
app.tenant_id) was set from the request context.
-- Confirm the tenant GUC is set the way RLS expects
SHOW app.tenant_id;
12. Production Patterns¶
12.1 Audit table archival¶
If you maintain audit/observability tables, archive and partition them to keep them fast:
-- Archive rows older than 90 days
CREATE TABLE IF NOT EXISTS app.tb_audit_log_archive (LIKE app.tb_audit_log);
INSERT INTO app.tb_audit_log_archive
SELECT * FROM app.tb_audit_log
WHERE created_at < NOW() - INTERVAL '90 days';
DELETE FROM app.tb_audit_log
WHERE created_at < NOW() - INTERVAL '90 days';
-- Or partition by month for faster time-range queries
CREATE TABLE app.tb_audit_log_2026_01 PARTITION OF app.tb_audit_log
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
12.2 Performance considerations¶
- Sampling — Trace at a low
sample_rate(e.g.0.1) in production; metrics are cheap, traces are not. - Async, non-blocking writes — Keep audit writes inside the same
fn_transaction so they succeed or roll back with the mutation, and avoid heavy synchronous side effects on the hot path. - External aggregation — Stream logs to an external aggregator (Splunk, DataDog, ELK) rather than querying large log tables in the database.
- Exclude noisy paths —
MetricsConfig.exclude_pathsandTracingConfig.exclude_pathskeep/health,/metrics, and docs routes out of your telemetry.
12.3 Multi-tenant observability¶
Always scope observability data by tenant. Tenant context flows
request → info.context["tenant_id"] → session GUC → Row-Level Security, so your audit queries and
per-tenant dashboards should filter explicitly:
-- Per-tenant dashboard query
SELECT object_type, status, COUNT(*) AS count
FROM app.tb_audit_log
WHERE tenant_id = $1 -- always filter by tenant
AND created_at > NOW() - INTERVAL '24 hours'
GROUP BY object_type, status;
Summary¶
Observability in FraiseQL is¶
- Two-layer — Application metrics/traces/logs plus PostgreSQL statistics
- Standards-based — Prometheus, OpenTelemetry, and standard Python logging
- Multi-tenant aware — Scope every observation by tenant via Row-Level Security
- Traceable — Correlation IDs link a request through GraphQL execution and SQL
- Queryable — Use SQL over
pg_stat_*and your own audit tables to aggregate metrics - Audit-ready — Application-defined audit tables capture user/tenant context for compliance
Key building blocks¶
fraiseql.monitoring—setup_metrics,MetricsConfig,HealthCheck, error tracking, notificationsfraiseql.tracing—setup_tracing,TracingConfig, OpenTelemetry spans, psycopg instrumentationfraiseql.caching—ResultCache/CacheStatsfor cache hit/miss visibility- PostgreSQL —
pg_stat_statements,pg_stat_activity,pg_stat_user_tables, andEXPLAIN ANALYZE
Troubleshooting¶
"No metrics appear at /metrics"¶
Cause: prometheus-client is not installed, or setup_metrics was not called.
Diagnosis¶
- Confirm the dependency:
uv pip show prometheus-client. - Confirm
setup_metrics(app, ...)runs at startup. - Curl the endpoint:
curl http://localhost:8000/metrics.
Solutions¶
- Install the optional dependency:
uv pip install prometheus-client. - Call
setup_metrics(app, MetricsConfig(enabled=True))aftercreate_fraiseql_app. - Verify
metrics_pathmatches your Prometheus scrape config.
"Traces are not exported"¶
Cause: OpenTelemetry SDK/exporter missing, tracing disabled, or no export endpoint configured.
Diagnosis¶
- Confirm the packages:
uv pip show opentelemetry-sdk opentelemetry-exporter-otlp. - Check
TracingConfig.enabledandexport_endpoint. - Confirm the collector is reachable from the app.
Solutions¶
- Install the SDK and exporter extras (see section 3).
- Set
export_endpointand a matchingexport_format(otlp/jaeger/zipkin). - Increase
sample_ratetemporarily while debugging.
"Logs are missing recent operations"¶
Cause: Log level too high, or logging not configured before the app starts.
Diagnosis¶
- Check the effective level:
logging.getLogger("fraiseql").getEffectiveLevel(). - Confirm
logging.basicConfig(...)runs before requests are served.
Solutions¶
- Lower the level:
logging.getLogger("fraiseql").setLevel(logging.DEBUG). - Add
logging.getLogger("fraiseql.sql").setLevel(logging.DEBUG)to log generated SQL. - Stream logs to an external aggregator (Splunk, DataDog, ELK) for retention.
"Correlation IDs not present in logs"¶
Cause: The client isn't sending a request-ID header, or the context_getter doesn't capture it.
Diagnosis¶
- Check inbound headers for
X-Request-ID(or your chosen header). - Verify the
context_getterwritesrequest_idintoinfo.context.
Solutions¶
- Always send a correlation ID from the client:
curl -H "X-Request-ID: abc-123" .... - Generate one in
context_getterwhen the header is absent (see section 6). - Include
request_idandtrace_idin your structured log formatter.
"Audit trail doesn't show who made a change"¶
Cause: User/tenant context not captured or not persisted by the fn_ function.
Diagnosis¶
- Confirm the JWT/auth middleware populates
info.contextwithuser_id/tenant_id. - Confirm your
fn_function writes those values into your audit table.
Solutions¶
- Extract
user_id/tenant_idincontext_getterand pass them tofn_inputs. - Persist them inside the mutation's
fn_function in the same transaction as the write. - For compliance, store a snapshot of the relevant fields in the audit row.
"Performance degradation after enabling detailed observability"¶
Cause: High trace sampling or heavy synchronous audit/logging on the hot path.
Diagnosis¶
- Compare latency with tracing/logging on vs off.
- Check database CPU via
pg_stat_statements. - Watch disk I/O if audit tables are large.
Solutions¶
- Lower
TracingConfig.sample_rate(e.g.0.05). - Keep audit writes inside the mutation transaction; avoid extra synchronous I/O per request.
- Archive/partition audit tables (section 12.1).
- Disable
DEBUG-level logging in production.
"Tenant data leaked in observability logs"¶
Cause: Sensitive data logged, or observations not scoped by tenant.
Diagnosis¶
- Audit log contents for PII/sensitive fields.
- Confirm every dashboard query filters by
tenant_id. - Review who can access the observability backends.
Solutions¶
- Sanitize logs: hash or mask PII before logging.
- Scope all observations by tenant (
WHERE tenant_id = $1) and rely on Row-Level Security. - Apply access controls on metrics/trace/log backends.
- Audit log contents regularly for compliance.
See Also¶
- Monitoring & Observability Guide — Prometheus, OpenTelemetry, health checks setup
- Observability Architecture — Technical architecture and design
- Production Deployment — Observability in production environments