One log call. Complete observability.
Stop writing separate code for logs, metrics, and traces. Write one log entry and automatically get:
- ✅ Structured logs (Azure Log Analytics, Loki, or local files)
- ✅ Metrics dashboards (Azure Monitor, Prometheus, Grafana)
- ✅ Distributed traces (Azure Application Insights, Tempo)
- ✅ Service dependency maps (automatic correlation)
Output is field-for-field identical to the TypeScript implementation — verified automatically by tools/validation/uis/compare-with-master.sh python, not just documented.
You write code for yourself during development. But you write logs for the operations engineer staring at a screen at 7 PM on Friday.
Picture this: your application just crashed in production. Everyone on your team has left for the weekend. The ops engineer who got the alert doesn't know your codebase, doesn't know your business logic, and definitely doesn't want to be there right now.
Make their job easy. Good logging is the difference between "some exception occurred somewhere" and "user authentication failed for email 'x@y.com' — invalid password attempt #3, account locked." One takes 3 hours to debug; the other takes 5 minutes.
# Traditional approach: separate code per signal
logger.info('Payment processed', extra={'order_id': '123'})
payment_counter.inc()
payment_duration.observe(duration)
with tracer.start_as_current_span('processPayment') as span:
span.set_attribute('order_id', '123')
# ... manually correlate logs, metrics, traces# sovdev-logger: 1 call, complete observability
FUNCTIONNAME = 'process_payment'
input_json = {'order_id': '123', 'amount': 99.99}
output_json = {'transaction_id': 'tx-456', 'status': 'approved'}
sovdev_log(SOVDEV_LOGLEVELS.INFO, FUNCTIONNAME, 'Payment processed',
PEER_SERVICES.PAYMENT_GATEWAY, input_json, output_json)
# ↑ Automatic logs + metrics + traces + correlationpip install -r requirements.txt # opentelemetry-api, opentelemetry-sdk, and the OTLP HTTP exporters(Not yet published to PyPI as a standalone package — install from this repo's python/ directory for now.)
Create test.py:
import sys
sys.path.insert(0, 'src') # or however you've placed the package on your path
from logger import (
sovdev_initialize, sovdev_log, sovdev_shutdown,
SOVDEV_LOGLEVELS, create_peer_services,
)
# INTERNAL is auto-generated — pass an empty dict if you have no external systems
PEER_SERVICES = create_peer_services({})
def main():
FUNCTIONNAME = 'main'
sovdev_initialize('my-app')
input_json = {'user_id': '123', 'action': 'process_order'}
output_json = {'order_id': '456', 'status': 'success'}
sovdev_log(
SOVDEV_LOGLEVELS.INFO,
FUNCTIONNAME,
'Order processed successfully',
PEER_SERVICES.INTERNAL,
input_json,
output_json,
)
sovdev_shutdown() # CRITICAL — see "Common Mistakes" below
if __name__ == '__main__':
main()python3 test.py- ✅ Console: human-readable colored output
- ✅ File: structured JSON in
./logs/dev.log - 📊 Want Grafana dashboards? → see Configuration
Identical structure to every other language implementation — same field names, same snake_case convention, verified by the same schema (tools/validation/schemas/log-entry-schema.json) and the automated master-comparison check. See the TypeScript README's Log Structure section for the full field-by-field examples (basic entry, error entry, job status, job progress) — they apply identically here.
One thing worth calling out explicitly: input_json/response_json are omitted entirely when you don't pass them at all, and present as null when you explicitly pass None — matching how the field-omission behaves in every language. Don't rely on Python's None-default alone to mean "omit"; if you want the field present as null, pass None explicitly rather than leaving the argument out (both currently produce the same practical result for most call sites, but the distinction matters if you're writing code that inspects raw log output).
# Define peer services once, near the top of your module
PEER_SERVICES = create_peer_services({
'PAYMENT_GATEWAY': 'SYS2034567', # External payment system (system ID)
# INTERNAL is auto-generated — no need to declare it
})
def process_payment(order_id: str, amount: float):
FUNCTIONNAME = 'process_payment' # BEST PRACTICE: one constant per function
input_json = {'order_id': order_id, 'amount': amount, 'currency': 'USD'}
try:
result = payment_gateway.charge(order_id, amount)
output_json = {'transaction_id': result.id, 'status': 'approved'}
sovdev_log(
SOVDEV_LOGLEVELS.INFO,
FUNCTIONNAME,
'Payment processed successfully',
PEER_SERVICES.PAYMENT_GATEWAY, # Track external dependency
input_json,
output_json,
)
return result
except Exception as error:
output_json = {'status': 'failed', 'reason': str(error)}
sovdev_log(
SOVDEV_LOGLEVELS.ERROR,
FUNCTIONNAME,
'Payment failed',
PEER_SERVICES.PAYMENT_GATEWAY, # Still track peer service on error
input_json,
output_json,
error, # Exception object for stack trace
)
raiseKey Points:
- ✅ One
FUNCTIONNAMEconstant per function — no typo-prone string literals - ✅ Explicit
input_json/output_jsonvariables — reused across success/error logs, not redefined inline - ✅ Track peer service even on errors — builds service dependency graphs
- ✅ Log both input AND output — complete audit trail
def import_users(users):
FUNCTIONNAME = 'import_users'
# 1. Log job START
sovdev_log_job_status(
SOVDEV_LOGLEVELS.INFO, FUNCTIONNAME, 'UserImportJob', 'Started',
PEER_SERVICES.INTERNAL, # Batch jobs are internal, not external calls
{'total_users': len(users), 'source': 'CSV'},
)
success_count = 0
failure_count = 0
# 2. Process each item and log PROGRESS
for i, user in enumerate(users):
try:
create_user(user)
success_count += 1
# BEST PRACTICE: log progress every N items, not every single one
if (i + 1) % 10 == 0 or i == len(users) - 1:
sovdev_log_job_progress(
SOVDEV_LOGLEVELS.INFO, FUNCTIONNAME, user.id,
i + 1, len(users), PEER_SERVICES.INTERNAL,
{'success_count': success_count, 'failure_count': failure_count},
)
except Exception as error:
failure_count += 1
# IMPORTANT: always log individual failures, don't skip errors
sovdev_log_job_progress(
SOVDEV_LOGLEVELS.ERROR, FUNCTIONNAME, user.id,
i + 1, len(users), PEER_SERVICES.INTERNAL,
{'email': user.email, 'error': str(error)},
)
# 3. Log job COMPLETION with final statistics
sovdev_log_job_status(
SOVDEV_LOGLEVELS.INFO, FUNCTIONNAME, 'UserImportJob', 'Completed',
PEER_SERVICES.INTERNAL,
{'total_users': len(users), 'success_count': success_count, 'failure_count': failure_count},
)Result in Grafana: query {job_name="UserImportJob"} to see the job lifecycle (Started → Progress → Completed); filter by ERROR level to see which specific users failed.
def main():
sovdev_log(SOVDEV_LOGLEVELS.INFO, FUNCTIONNAME, 'Test', PEER_SERVICES.INTERNAL, input_json)
# Missing: sovdev_shutdown()
# Result: last logs lost!Fix: in a short script, always call sovdev_shutdown() before exit — not sovdev_flush(), which never shuts anything down. Both are synchronous in Python, unlike TypeScript's await sovdev_shutdown().
def main():
try:
sovdev_log(SOVDEV_LOGLEVELS.INFO, FUNCTIONNAME, 'Test', PEER_SERVICES.INTERNAL, input_json)
finally:
sovdev_shutdown() # ✅ Runs even if the try block raisesIn a long-running server, don't call sovdev_shutdown() per-request — call sovdev_flush() if you want telemetry out sooner (safe to call repeatedly), and sovdev_shutdown() exactly once, in your shutdown handler.
Same rationale as every other language implementation: prevents typos, makes refactoring safer, keeps logs filterable by function name. See the TypeScript README's version of this section — identical guidance applies here with Python syntax.
Use create_peer_services() constants instead of raw string literals like 'SYS1234567' — same reasoning as TypeScript: single source of truth, easy to update, and it's what makes automatic service-dependency maps in Grafana possible.
Naming convention: Python uses the same snake_case function and field names as every other language implementation (sovdev_log, sovdev_initialize, create_peer_services) — this is a deliberate cross-language consistency choice, not incidental overlap with Python's own naming conventions.
def sovdev_initialize(
service_name: str,
service_version: Optional[str] = None,
peer_services: Optional[Dict[str, str]] = None,
) -> NoneInitialize the logger with service information and peer system mappings. Must be called once at application startup, before any logging.
service_name(required) — unique identifier for your serviceservice_version(optional) — auto-detected fromSERVICE_VERSION/PYTHON_VERSIONenv vars if omitted, falls back to"1.0.0"peer_services— passPEER_SERVICES.mappingsfromcreate_peer_services(); enables service dependency maps in Grafana
def sovdev_log(
level: SovdevLogLevel,
function_name: str,
message: str,
peer_service: str,
input_json: Any = None,
response_json: Any = None,
exception: Optional[BaseException] = None,
) -> NoneGeneral purpose logging function. input_json/response_json: omit entirely for "no data" (field absent from output); pass None explicitly for "field present, value null". exception: pass the caught exception object to capture type/message/stacktrace automatically (credentials are stripped, stack limited to 350 characters).
Note: unlike TypeScript's sovdev_log, there is currently no manual trace_id override parameter — trace correlation is handled entirely through sovdev_start_span()/sovdev_end_span().
def sovdev_log_job_status(
level: SovdevLogLevel,
function_name: str,
job_name: str,
status: str,
peer_service: str,
input_json: Any = None,
) -> NoneTrack job lifecycle events. status: e.g. 'Started', 'Completed', 'Failed'. job_name: a stable identifier for filtering in Grafana (e.g. 'UserSyncJob').
def sovdev_log_job_progress(
level: SovdevLogLevel,
function_name: str,
item_id: str,
current: int,
total: int,
peer_service: str,
input_json: Any = None,
) -> NoneProgress logging for batch/array processing — produces "Processing item 45 of 100" style logs. current is 1-based.
def sovdev_start_span(operation_name: str, attributes: Optional[Dict[str, Any]] = None) -> Span
def sovdev_end_span(span: Span, error: Optional[BaseException] = None) -> NoneWrap an operation in a span for distributed tracing — logs made between sovdev_start_span() and sovdev_end_span() automatically inherit the span's trace_id/span_id.
span = sovdev_start_span('lookup_company', {'org_number': org_number})
try:
result = lookup(org_number)
sovdev_log(SOVDEV_LOGLEVELS.INFO, FUNCTIONNAME, 'Lookup complete',
PEER_SERVICES.BRREG, {'org_number': org_number}, result)
except Exception as error:
sovdev_end_span(span, error)
raise
else:
sovdev_end_span(span)def sovdev_flush() -> NoneForce-export any buffered logs, metrics, and traces right now. Safe to call any number of times — this does not shut anything down. Use it in a long-running server whenever you want telemetry out sooner than the normal batch interval. For the true end of a process, use sovdev_shutdown() instead. Synchronous (no await — unlike TypeScript's async sovdev_flush()). Blocks up to 30 seconds. Safe to call from finally blocks and signal handlers.
def sovdev_shutdown() -> NoneForce-flush, then permanently shut down every telemetry provider. Call this exactly once — the last thing before your process exits. After this call, logging/metrics/tracing stop working for the rest of the process's life. Synchronous (no await — unlike TypeScript's async sovdev_shutdown()). Blocks up to 30 seconds.
Long-running servers: never call sovdev_shutdown() per-request. Call sovdev_flush() (freely, repeatedly) if you want telemetry out sooner, and sovdev_shutdown() exactly once, in your process's shutdown handler.
Two optional diagnostic functions exist in the TypeScript implementation but not yet here — flagged explicitly rather than left for you to discover by a confusing ImportError:
sovdev_validate_config()— validates required OTLP environment variables are set before initializationsovdev_test_otlp_connection()— tests connectivity to the OTLP logs/metrics/traces endpoints
Neither is required for normal operation (both are development/debugging aids). If you need them, check TypeScript's implementation for the reference behavior to port.
Local development: no configuration needed — just install and run. Logs to console (colored) and ./logs/dev.log (JSON) by default.
Production / OTLP backends (Azure Monitor, Grafana/Loki/Prometheus/Tempo): configure via environment variables — the same OTEL_EXPORTER_OTLP_{LOGS,METRICS,TRACES}_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS variables used by every language implementation, since this library sits on the standard OpenTelemetry Python SDK. See the TypeScript README's Configuration section for the exact endpoint values and header requirements for each deployment scenario (local Traefik routing, in-cluster Kubernetes DNS, Azure Application Insights, Grafana Cloud) — they're environment-variable-driven and apply identically regardless of language.
Environment variables specific to file logging:
LOG_TO_FILE=true
LOG_FILE_PATH=./logs/dev.log # optional, custom path
ERROR_LOG_PATH=./logs/error.log # optional, custom error-only path
LOG_TO_CONSOLE=true # optional, defaults based on whether OTLP is configuredImplements the same "Loggeloven av 2025" requirements as every other language implementation:
- ✅ Structured JSON format with a consistent, schema-validated shape
- ✅ Required fields on every log:
service_name,function_name,timestamp,trace_id,event_id - ✅
snake_casefield naming throughout (no camelCase, no dotted notation) - ✅ Flat exception fields (
exception_type,exception_message,exception_stacktrace) — never nested - ✅ Security: credentials automatically stripped from exception stack traces before truncation
- ✅ Distributed tracing via OpenTelemetry span/trace correlation
This section is for library contributors. If you're a library user, you don't need it.
git clone https://github.com/helpers-no/sovdev-logger.git
cd sovdev-logger/python
pip install -r requirements.txtcd test/e2e/company-lookup
./run-test.sh # E2E test + file-log validationCross-language conformance — the check that actually matters for "does this match TypeScript":
cd tools/validation/uis
./compare-with-master.sh pythonSee tools/validation/uis/README.md for the complete validation sequence, and website/docs/ai-developer/plans/completed/PLAN-001-master-comparison-mode.md for how this conformance check works and what it does/doesn't cover.
- Fork the repository
- Create a feature branch
- Make your changes
- Run
./compare-with-master.sh python— it must pass with zero mismatches - Commit, push, open a pull request
MIT