Skip to main content

wshtlib

Lightweight observability library for AWS Lambda and FastAPI. Zero external dependencies.

A focused alternative to aws-powertools — covers structured logging, CloudWatch metrics (EMF), request context propagation, and Lambda handler boilerplate. Nothing more.

Install

pip install wshtlib

FastAPI/Starlette middleware is optional:

pip install wshtlib[fastapi]

Usage

Lambda handler

Two decorators, one per invocation mode.

@bootstrap — synchronous invocations (API Gateway), where the return value is the response:

from wshtlib import bootstrap, get_logger

logger = get_logger("my-service")

@bootstrap
def handler(event, context):
    logger.info("invoked", path=event.get("path"))
    return {"statusCode": 200}

It handles:

  • Warming events ("source": "lambda-warming") — returns 200 early
  • Context init and structured log enrichment
  • Unhandled exceptions — logs error, returns 500

@worker — asynchronous invocations (S3, EventBridge, SQS), where the return value is discarded:

from wshtlib import worker, get_logger

logger = get_logger("my-worker")

@worker
def handler(event, context):
    logger.info("processing", records=len(event["Records"]))

Same context init and structured error logging, but the exception is re-raised rather than swallowed — retries, on_failure destinations, the DLQ, and the Errors metric all depend on Lambda seeing the invocation fail. No warming-event handling.

Structured logging

from wshtlib import get_logger

logger = get_logger("my-service")
logger.info("user signed in", user_id="u_123", plan="pro")

Output is JSON to stdout, enriched with level, timestamp, service, logger, location, runtime fields, and Lambda context on invocation. location names the calling function and line. logger is the name passed to get_logger; service names the deployment unit and is resolved the same way metrics resolve it — see Service name.

Keyword arguments are the preferred spelling, but stdlib's extra={...} works too and lands in the same JSON entry; kwargs win if both supply the same key. Fields are also set as attributes on the LogRecord, so custom filters and %(field)s formatters can read them.

Field names are unrestricted — including msg, args, and level. Only exc_info, extra, stack_info, and stacklevel keep their stdlib meanings and cannot be used as fields. A field whose name collides with one the formatter owns (level, message, timestamp, service, logger, location, trace_id, exception, and the runtime/Lambda fields) is emitted with an extra_ prefix rather than replacing it:

logger.info("subscription renewed", level="premium")
# {"level": "INFO", ..., "message": "subscription renewed", "extra_level": "premium"}

This keeps an enrichment field from falsifying the record it was meant to enrich.

CloudWatch metrics (EMF)

from wshtlib.metrics import metrics

metrics.count("OrderPlaced")
metrics.put("Duration", 142.5, unit="Milliseconds")
metrics.flush()

metrics is a module-level MetricsContext instance, and @bootstrap/@worker flush it for you when the handler returns. For isolated contexts (e.g. per-request), instantiate MetricsContext() directly — one you create yourself is one you flush yourself.

A context is safe to record into and flush from several threads at once. What sharing one still costs you is attribution: a shared context accumulates everything into a single document and resolves its dimensions when it flushes, so under a concurrent server a metric recorded while serving one request can be flushed by another and stamped with that request's service. Where that matters, use a context per request or bind service= when you construct it.

A namespace is required. Pass MetricsContext(namespace=...) or set WSHT_METRICS_NAMESPACE; flush raises RuntimeError if neither does. It is resolved per flush, so setting the variable after import works.

Recording the same name more than once keeps every value rather than replacing it:

metrics.count("OrderPlaced")
metrics.count("OrderPlaced")
metrics.put("Duration", 50.0, unit="Milliseconds")
metrics.put("Duration", 60.0, unit="Milliseconds")
# {"OrderPlaced": [1.0, 1.0], "Duration": [50.0, 60.0], ...}

CloudWatch derives Sum, Average, Minimum, Maximum and SampleCount from those arrays, so a counter's total is its Sum. A name recorded once serialises as a bare number. Recording one name under two different units raises ValueError — a single metric definition carries a single unit, and picking one silently would mislabel real measurements.

Three names are refused outright, also with ValueError: service, environment, and _aws. Metric values sit at the root of the document beside the dimensions and the directive that describes it, so a metric borrowing one of those names overwrites it — and CloudWatch rejects the resulting document whole, losing every metric in it.

EMF caps a document at 100 metric definitions and 100 values per metric, and CloudWatch rejects an over-limit document whole — losing every metric in it, not just the one that overflowed. Crossing either limit therefore flushes the accumulated metrics and starts a new document, so put may write before you call flush.

Service name

Logging and metrics resolve the service through resolve_service, taking the first that supplies a value:

  1. an explicit argument — MetricsContext(service=...)
  2. the request context — set_service("checkout")
  3. WSHT_SERVICE_NAME
  4. AWS_LAMBDA_FUNCTION_NAME, which Lambda always sets

A log line and a metric emitted from the same context therefore report the same service. If nothing supplies a value, logs fall back to the logger's own name and metrics omit the dimension rather than invent one.

set_service is initialisation-time configuration: call it at import, where it survives the context reset that @bootstrap and @worker perform on every invocation. It travels the way any ContextVar does, which is to say not into threads — a def endpoint running in Starlette's threadpool, anything under TestClient, or a call made from a FastAPI lifespan handler will not see it. Use WSHT_SERVICE_NAME for a value that must hold process-wide.

Keep dimensions low-cardinality: every unique combination becomes its own CloudWatch metric and bills accordingly.

Request context

from wshtlib import get_context, set_user_id

set_user_id(claims["sub"])
ctx = get_context()  # {"trace_id": ..., "correlation_id": ..., "user_id": ...}

Context is stored in a ContextVar — safe for concurrent async handlers.

FastAPI middleware

from fastapi import FastAPI
from wshtlib.middleware import WshtlibMiddleware

app = FastAPI()
app.add_middleware(WshtlibMiddleware)

Initialises request context, logs method, path, status, duration_ms per request, and injects X-Trace-Id into the response.

A request whose handler raises is logged too — at error, with status 500 and the traceback — and the exception is then re-raised so your own exception handlers still decide the response. No X-Trace-Id accompanies that path, there being no response yet to carry it.

Utilities

from wshtlib import require_env, require_https_url, require_secret

db_url = require_env("DATABASE_URL")          # raises RuntimeError if missing/empty
endpoint = require_https_url(require_env("API_URL"))  # raises ValueError if not https
api_key = require_secret("api/key")           # raises RuntimeError if missing/empty, cached

Secrets are cached for WSHT_SECRET_CACHE_TTL seconds, 300 by default. The lifetime is the point: a Lambda execution environment outlives a rotation by hours, so a secret cached indefinitely goes on being served after it stops working. Call clear_secret_cache() to discard the cache at once.

Environment variables

Every variable wshtlib reads is prefixed, so nothing else in the environment can steer it by accident.

Variable Default Description
WSHT_LOG_LEVEL INFO Logger level. Case-insensitive; a blank or unrecognised value leaves the default in place rather than failing the import
WSHT_METRICS_NAMESPACE — CloudWatch namespace. Required unless passed to MetricsContext
WSHT_SERVICE_NAME — service dimension and log field, unless set explicitly
WSHT_ENVIRONMENT — Added as a metrics dimension if set
WSHT_SECRET_CACHE_TTL 300 Seconds a secret stays cached; 0 disables caching

Development

uv sync --group dev
uv run pytest
uv run mypy wshtlib
uv run ruff check wshtlib

License

MIT

Metadata

Release files for wshtlib 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for wshtlib 0.5.0
File Size Uploaded
wshtlib-0.5.0.tar.gz 79.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for wshtlib 0.5.0
File Interpreter ABI Platform
wshtlib-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 101.1 kB

Release files / wshtlib-0.5.0.tar.gz

Download URL wshtlib-0.5.0.tar.gz
Size 79.9 kB
Tags Source
SHA-256 checksum
How to use checksums
67446f00a630458012f5f829753d9feec937b48042e7fa7a911e0d42c3f3e2c6
BLAKE2b-256 checksum
How to use checksums
3dacd842789f41ee32bcc12d7e0b0a517b5f9d27267822b229448cd2a7584d2b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 8, 2026.

Transparency log

Release files / wshtlib-0.5.0-py3-none-any.whl

Download URL wshtlib-0.5.0-py3-none-any.whl
Size 21.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
de01bff135abb8fc97d480cc91ee44d64d963edbc58d2c5e843690352f969b44
BLAKE2b-256 checksum
How to use checksums
7e398d576ce0d63344cd914fc5f2a419f12ee92de93bc9115f40909535bc3c4a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 8, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page