Metadata-Version: 2.5
Name: apphelm
Version: 0.8.0
Summary: Official Python server SDK for Apphelm: events, identify, fail-closed feature flags, errors, tracing and logs.
Project-URL: Homepage, https://apphelm.dev
Project-URL: Documentation, https://apphelm.dev/docs
Author-email: SansaVision <support@sansavision.com>
License-Expression: LicenseRef-Proprietary
License-File: LICENSE
Keywords: analytics,apphelm,celery,django,error-monitoring,fastapi,feature-flags,flask,logging,tracing
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# Apphelm Python SDK

The official Python client for [Apphelm](https://apphelm.dev), for server-side code: product
events, identify, alias and groups, fail-closed feature flags, error and message reporting with scopes
and breadcrumbs, tracing, release health and cron check-ins, plus opt-in integrations for Django, Flask,
FastAPI/Starlette, Celery, requests, httpx and SQLAlchemy. Standard library only, Python 3.10 or later.

```bash
pip install apphelm
```

Use a server API key (`ahl_…`) from your Apphelm workspace and keep it secret. Never ship it in
a browser or mobile app.

```py
import os
from apphelm import Apphelm

apphelm = Apphelm(api_key=os.environ["APPHELM_API_KEY"])

# A product event for one person.
apphelm.capture("org_…", "prd_…", distinct_id=customer.id, event_name="invoice_sent")

# Feature flags fail closed: any network, timeout or API error evaluates to False.
if apphelm.is_enabled("new-checkout", customer.id, product_id="prd_…"):
    render_new_checkout()
variant = apphelm.get_feature_flag("org_…", "prd_…", "pricing-test", customer.id)  # variant, True or False
```

`get_feature_flag` answers `True` for an enabled boolean (or remote config) flag, the variant
string for a multivariate one, and `False` otherwise. Before 0.7.0 it answered `"on"` for an
enabled boolean flag. Exposures still record the variant the server served (`"on"` for a boolean
flag).

## LLM analytics

`apphelm_llm` (in the same package, standard library only) records the calls your server makes to its own model provider as PostHog-compatible `$ai_*` events. AppHelm never calls a model: the wrappers sit around your existing OpenAI or Anthropic client, sync or async, and send AppHelm what happened.

```py
from apphelm_llm import wrap_openai, wrap_anthropic, capture_ai_generation, capture_ai_feedback, capture_ai_metric, flush

client = wrap_openai(OpenAI(), apphelm, "org_…", "prd_…", distinct_id=user.id)
client.chat.completions.create(model="gpt-5-mini", messages=messages, apphelm={"trace_id": request_id})

claude = wrap_anthropic(AsyncAnthropic(), apphelm, "org_…", "prd_…", distinct_id=user.id)
await claude.messages.create(model="claude-haiku-4-5", max_tokens=512, messages=messages, stream=True)

capture_ai_generation(apphelm, "org_…", "prd_…", model="llama-3.3-70b", provider="self-hosted", distinct_id=user.id, input_tokens=812, output_tokens=96, latency_seconds=0.64)
capture_ai_feedback(apphelm, "org_…", "prd_…", trace_id=request_id, text="That fixed it", distinct_id=user.id)
```

- `wrap_openai` records `chat.completions.create`, `responses.create` and `embeddings.create`; `wrap_anthropic` records `messages.create`. Streams are recorded when they end.
- Prompts and completions are sent only with `capture_content=True` (on the wrapper, or `apphelm={"capture_content": True}` per call), bounded to 20,000 characters of JSON per field.
- Events go on a background thread; call `flush()` before a short-lived process exits, or pass `sync=True`. A failed send goes to `on_error` and never into your code; a failed model call is recorded and re-raised.
- Run its tests with `python3 -m unittest test_apphelm_llm`.

See [LLM analytics](https://apphelm.dev/docs#sdk) for the event contract and how cost is worked out.

## Identify, alias and groups

These send the same events as every other Apphelm SDK (`$identify`, `$create_alias`,
`$groupidentify`, `$groups`; see docs/SDK.md "Identity").

```py
# At sign-in. anon_distinct_id is the id the person had before (an anonymous browser or device id
# you forwarded): their earlier events then count for them. traits are sent as $set.
apphelm.identify("org_…", "prd_…", distinct_id=account.id, traits={"plan": "builder"},
                 anon_distinct_id=request.cookies.get("anon_id"), set_once={"signup_source": "ads"})

# Link another id of the same person (an id from an older system, say) to this one.
apphelm.alias("org_…", "prd_…", distinct_id=account.id, alias=legacy_id)

# Group properties. The group type "account" is the account Node group() names; any other type
# is its own account. name and plan fill the account's name and plan.
apphelm.group("org_…", "prd_…", "company", company.id, {"name": company.name, "plan": "scale"})

# Attach an event to its groups.
apphelm.capture("org_…", "prd_…", distinct_id=account.id, event_name="report_exported", groups={"company": company.id})
```

- An earlier id is linked one way, earlier → identified. It is never linked when it already
  belongs to another identified person: two identified customers are never merged
  automatically, and the pair goes to the identity review queue instead.
- `traits` and `set_once` become customer traits under your customer model, consent and plan
  limits, recorded as product-event, system-observed provenance (`set_once` only fills traits
  the customer has none of yet). Trait keys must be lowercase letters, digits and `_`;
  credential and special-category keys are left out.
- For explicit provenance, pass `trait_sources`, one entry per trait. The call is then sent as
  `apphelm.identify`, and a trait without a matching source (or a source without a trait) raises
  `ValueError` before anything is sent, because the API would reject it:

```py
apphelm.identify("org_…", "prd_…", account.id, traits={"plan": "builder"},
                 trait_sources={"plan": {"sourceType": "auth", "sourceId": "session", "verification": "integration_verified"}})
```

`external_user_id=` still works as another name for `distinct_id`.

## Errors and messages

```py
try:
    charge(order)
except Exception as error:
    apphelm.capture_exception("org_…", "prd_…", error, release=os.environ.get("RELEASE"),
                              environment="production", tags={"route": "/charges"}, user_id=account.id)

# Report uncaught exceptions (main thread and other threads) as unhandled issues.
# The previous hooks still run, so the traceback prints and the process exits as before.
restore = apphelm.capture_unhandled("org_…", "prd_…", release=os.environ.get("RELEASE"), environment="production")

apphelm.capture_message("org_…", "prd_…", "Payment provider fell back to retries", level="warning")
```

Exception and message text, tracebacks, tags, breadcrumbs, span descriptions and data, log
lines and LLM error text are scrubbed in the process before `before_send` sees them and before
they are sent: private keys, JWTs, AppHelm, OpenAI, Anthropic, Stripe, AWS, GitHub, Slack and
Google keys, bearer/basic credentials, `password=`/`token=`-style values, secret query
parameters, URL passwords, email addresses and long hex tokens (`apphelm_scrub`, the same rules
as every AppHelm SDK and the server). It can't be turned off; the server scrubs again. Ids,
release, environment, numbers and fingerprints are left alone, and `user_id` is sent only as a
hash.

Pass `before_send=` to the constructor to change or drop any event before it is sent, as with
PostHog: captures, identify, alias, groups, flag exposures, transactions, exceptions, messages
and LLM events. The hook gets the event body (`eventName`, `properties`, `workspaceId`,
`productId`, `eventId`, `privacyMode`, and `occurredAt` when set) and returns it, changed or not,
or `None` to drop the event. A hook that raises drops the event.

```py
def before_send(event):
    if event["eventName"] == "$exception":
        event["properties"]["message"] = event["properties"]["message"].replace(customer_name, "[customer]")
    return event
```

From 0.7.0 the hook sees every event and is given the whole event. In 0.6.0 it saw only
exceptions and messages and was given their properties: such a hook now reads
`event["properties"]` and checks `event["eventName"]` (both are sent as `$exception`).

## Error monitoring and tracing with `init`

For a web app or a worker, configure AppHelm once at start-up. `apphelm.init` creates a client
(sending from a background thread, so no request waits on AppHelm) and binds it with your
workspace and product, so the module-level functions and the framework integrations below need
no ids. (From here on `apphelm` is the module; the examples above call a client `apphelm`, and
below an explicit client is `apphelm_client`.)

```py
import os
import apphelm

apphelm.init(
    api_key=os.environ["APPHELM_API_KEY"], workspace_id="org_…", product_id="prd_…",
    release=os.environ.get("RELEASE"), environment="production",
    traces_sample_rate=0.2,                                # turns on automatic tracing: share of new traces kept
    trace_propagation_hosts=["api.example.com", ".internal.example.com"],
)

apphelm.capture_exception(error)                           # or with no argument inside `except`
apphelm.capture_message("Payment provider fell back to retries", level="warning")
apphelm.flush()                                            # before a short-lived script exits
```

`init` also reports uncaught exceptions (`capture_unhandled=False` to skip) and records
`logging` records at warning and above as breadcrumbs (`logging_breadcrumbs=False` to skip, or
pass a level). It wraps `Logger.callHandlers` instead of adding a handler, so your log output
is unchanged. Pass `client=Apphelm(...)` to bind a client you built yourself. Every explicit API
(`apphelm_client.capture_exception("org_…", "prd_…", …)`) keeps working, with or without `init`.
Without `init` the module-level functions do nothing.

### Filtering, sampling and switching off

```py
import re

apphelm.init(
    api_key=os.environ["APPHELM_API_KEY"], workspace_id="org_…", product_id="prd_…",
    enabled=os.environ.get("APP_ENV") != "test",           # False: nothing is sent at all
    sample_rate=0.5,                                       # keep half of all errors and messages
    ignore_errors=["ResizeObserver loop", re.compile(r"^Timeout after \d+s")],
    ignore_transactions=["GET /health", re.compile(r"^GET /metrics")],
    deny_urls=["/site-packages/noisy_sdk/"],               # errors raised in these files
    allow_urls=[re.compile(r"^/srv/shop/")],               # only errors raised in these files
    debug=True,                                            # the SDK's own diagnostics on stderr
)
```

Every exception, message and `capture_event` (unhandled ones included) goes through these
steps in order: `enabled`, `ignore_errors`, `deny_urls`, `allow_urls`, `sample_rate`, then
the event is built and scrubbed, then `before_send`.

- `enabled=False` sends nothing: no events, transactions, profiles, sessions, check-ins,
  feedback or log lines. Calls return `{"accepted": False, "dropped": True}` (or `None`), flag
  reads still work, and a `monitor`ed job still runs.
- `ignore_errors` drops an error whose message, type or `"Type: message"` matches: a string
  matches as a substring, a compiled regular expression with `search`.
- `deny_urls` drops an exception whose top stack frame, the innermost one where it was raised
  (`traceback.extract_tb(error.__traceback__)[-1].filename`), is in a file that matches; a
  non-empty `allow_urls` drops one whose top frame's file matches none of it. Patterns are
  matched as in `ignore_errors` (case-sensitive substrings, or compiled regular expressions
  with `search`), and `deny_urls` wins over `allow_urls`. An exception without a traceback
  (created but never raised) is kept, and messages are never affected. For `capture_event` the
  file comes from the exception's `stacktrace`: the last `File "..."` line of Python traceback
  text, or the last of Sentry's `frames` (`abs_path`, else `filename`). Sentry's Python SDK has
  no equivalent (its `in_app_include`/`in_app_exclude` only mark frames); these are AppHelm's
  own, with the same meaning as the JavaScript SDK's `denyUrls`/`allowUrls`.
- `sample_rate` (0–1, default 1) is the share of errors and messages kept. Transactions,
  analytics events, logs and feedback are not affected (`traces_sample_rate` samples traces).
- `ignore_transactions` drops transactions whose name matches (checked when they start, so they
  are never profiled, and again when they finish, after a rename to a route template).
- `debug=True` prints why an event was dropped and which sends failed or were refused, never the
  event's contents.

All of these are `Apphelm(...)` constructor options too.

### Events, last event id, flush and close

```py
event_id = apphelm.capture_event({                      # Sentry's capture_event
    "exception": {"type": "PaymentError", "value": "card declined", "stacktrace": traceback_text},
    "level": "error", "fingerprint": ["payments"], "tags": {"route": "/charge"},
    "contexts": {"order": {"plan": "pro", "items": 3}},
})
apphelm.capture_event({"message": "Disk 91% full", "level": "warning"})

apphelm.last_event_id()           # the id of the last exception, message or event captured
apphelm.flush(timeout=2.0)        # True when queued events and log lines were sent in time
apphelm.close(timeout=2.0)        # flush, then stop sending and undo init; True when everything was sent
```

`capture_event` takes `message` (or Sentry's `logentry`), `level`, `exception` (`{"type", "value",
"stacktrace"}`, or Sentry's `{"values": [...]}`), `fingerprint`, `tags`, `contexts` (checked like
`set_context`, for this event only), `release`, `environment`, `handled`, `user_id` (or
`user: {"id"}`, sent only as a hash) and `event_id`. It is sent exactly as `capture_exception`
(with an exception) or `capture_message` (without) would send it, scrubbed before `before_send`,
and returns the event id, or `None` when it was dropped. On a client:
`apphelm_client.capture_event("org_…", "prd_…", event)`, `apphelm_client.last_event_id()` and
`apphelm_client.close(timeout)` (later sends are dropped without a request; flag reads keep
working). `shutdown(timeout)` now returns the same bool as `flush`.

### User feedback

```py
apphelm.capture_exception(error)
apphelm.capture_feedback("The export failed twice", associated_event_id=apphelm.last_event_id())
# On a client: apphelm_client.capture_feedback("…", associated_event_id=…, product_id="prd_…")
```

Feedback is stored as `human` feedback with `private_analysis` permission and shows on the
issue of `associated_event_id` (the `last_event_id()`, or a Sentry event id). Emails, keys and
query strings in the message are redacted and it is cut to 5,000 characters. `name` and `email`
are accepted for Sentry compatibility and never sent. The key needs the `feedback:write` scope.
An empty message raises `ValueError`.

### Scope: tags, users and breadcrumbs

```py
apphelm.set_tag("region", "eu")                 # global: every later event
with apphelm.new_scope():                       # your own unit of work (integrations open one per request/task)
    apphelm.set_user(account.id)                # sent only as user_hash, never the id
    apphelm.set_tag("plan", "scale")
    apphelm.set_context("order", {"plan": "pro", "items": 3})  # sent as context.order.* tags
    apphelm.add_breadcrumb("Cart priced", category="checkout", data={"items": 3})
    apphelm.capture_exception(error)            # carries the tags, user hash, breadcrumbs and trace id
```

- Each request or task an integration handles runs in its own scope (a `contextvars` variable, so
  threads and asyncio tasks never share one); the global scope applies everywhere.
- Exceptions and messages carry the scope's tags (up to 20), `user_hash`, the last 30
  breadcrumbs (`{at, category, message}`), the release-health `session_hash`, and the active
  span's `trace_id`. Arguments you pass (`tags=`, `user_id=`, `trace_id=`) win.
- `set_context(name, values)` (Sentry's `set_context`) sends a context's values as
  `context.<name>.<key>` tags. They go after the other tags: at most 10 of them, and 20 tags
  in all. Only str, int, float and bool values are kept, at most 10 contexts of 10 values each.
  Strings go through the shared scrubber (`apphelm_scrub`: secrets, emails and query strings). Keys that look like secrets or
  contact details (password, token, email…) are dropped. `None` removes a context.
- Breadcrumbs are kept in a bounded buffer (`max_breadcrumbs=100` on `init`); query strings,
  secrets and emails are cut from their messages (before and after `before_breadcrumb`). `before_breadcrumb=lambda crumb, hint: crumb` sees each one
  (with `level` and `data`, which stay local) and returns it, changed or not, or `None` to drop
  it. Automatic crumbs: `log` (logging), `http` (requests/httpx), `query` (opt-in, SQL).

### Tracing and spans

Automatic tracing (transactions from installed middleware and integrations, automatic screen loads and app starts) is off until you set a trace sample rate (`traces_sample_rate`, `tracesSampleRate` or a `tracesSampler`); an incoming sampled `traceparent` is always honoured, and transactions you start yourself are kept at a rate of 1 when no rate is set.

Until then the integrations still give each request its own scope, breadcrumbs and error
capture, and errors still carry a trace id: the request's transaction exists but is
unsampled, so it sends nothing and an outgoing `traceparent` carries flag `00`.

```py
# Continue the caller's trace (from the browser or another service), or start a new one.
with apphelm.start_transaction("POST /charges", op="http.server",
                               traceparent=request.headers.get("traceparent"),
                               sentry_trace=request.headers.get("sentry-trace")) as tx:
    with apphelm.start_span("db.query", "select * from orders where id = ?", data={"db.system": "postgresql"}):
        with apphelm.start_span("cache.get", "order", data={"cache.hit": False, "cache.key": "order:42"}):
            ...
    tx.set_http_status(200)
```

- `with` makes a transaction or span the active one: `apphelm.start_span` nests under it (a
  no-op when nothing is active, so instrumented code needs no checks) and exceptions captured
  inside link to its trace.
- `data` takes the span attributes AppHelm indexes: `http.request.method`,
  `http.response.status_code`, `server.address`, `url.full` (sent without query, fragment or
  user info), `db.system`, `cache.hit`, `cache.key`, `messaging.destination.name` and
  `messaging.message.receive.latency`. Descriptions keep 2,000 characters; a transaction keeps
  50 spans.
- An incoming `traceparent` (or, failing that, Sentry's `sentry-trace`) is continued with the
  caller's sampling decision. New traces are kept with `traces_sample_rate` (not set by
  default), or by `traces_sampler=lambda context: …` (`{"name", "op", "parent_sampled"}` → a
  rate or bool); either applies to the integrations' transactions and to yours.
- Outgoing: `apphelm.trace_headers(url)` returns `{"traceparent": …}` only for hosts in
  `trace_propagation_hosts` (an exact host, or `.example.com` for every host under it; empty by
  default, because the header tells the receiver your trace id). `apphelm_http` adds it for you.
- Without `init`: `Apphelm(api_key, traces_sample_rate=0.2, trace_propagation_hosts=[…])` and
  `client.start_transaction("org_…", "prd_…", "POST /charges", traceparent=…)` work as before.

### Profiling (opt-in)

`profiles_sample_rate` (0–1, default off) profiles that share of *sampled* transactions:

```py
apphelm.init(api_key, workspace_id="org_…", product_id="prd_…", traces_sample_rate=0.2, profiles_sample_rate=0.5)
```

- A daemon thread samples the stack of the thread that started the transaction every 10 ms
  with `sys._current_frames()`, from `start_transaction` to `finish()` (or the end of the
  `with` block), for at most 30 seconds or 3,000 samples. Concurrent transactions share the one
  sampler thread, each recording its own thread; the thread exits when none is profiled.
- Frames are the function (`co_qualname`), module, file (relative to the working directory,
  from the package directory for installed packages, else just the file name) and line. Local
  variables (`f_locals`) are never read.
- The profile is sent to `/api/v1/profiles` in Sentry's sample format v1 (at most 1 MB, else
  dropped) when the transaction finishes, and the transaction carries its id as `profile_id`.
  It doesn't pass through `before_send`.
- Overhead: the sampler holds the GIL briefly each tick; while a CPU-bound thread holds the GIL
  samples come less often (expect 60–100 per second). See docs/SDK.md, "Profiling".

### Release health (sessions)

Off by default, and sent only when a `release` is set:

```py
apphelm.init(..., release="3.1.0", release_health="process")   # or "request"
```

- `"process"`: one `$session` per process (each forked gunicorn or Celery worker too). An
  uncaught exception reported by the global handler, or any unhandled request or task error,
  carries its `session_hash` and marks it crashed. Best for workers, CLIs and daemons.
- `"request"`: one `$session` per request an integration handles, ended `ok` or `crashed`; the
  request's unhandled exception carries its `session_hash`. **Each request then costs one
  event.**

## Framework integrations

Each integration is a separate module in this package, opt-in, and imports nothing until you
use it: the core needs no framework installed. All of them need `apphelm.init(...)` first and
never let a telemetry failure break a request, task or query. Their transactions are sent only
once you set `traces_sample_rate` (or `traces_sampler`) on `init`, or when the caller's
`traceparent` is sampled; without one they still capture errors with a trace id.

**Users.** The Django, Flask and ASGI integrations add the signed-in user's id to the
exceptions they capture, **as a hash only** (`user_hash`, so issues can count affected users);
the id itself, emails, usernames and IPs are never sent. Collecting it under the right legal
basis and consent is your responsibility as the developer. To turn it off, pass
`capture_user=False` (`apphelm_flask.init_app(app, capture_user=False)`,
`app.add_middleware(ApphelmMiddleware, capture_user=False)`), or for Django set
`APPHELM = {"capture_user": False}`.

### Django

```py
# settings.py
import os, apphelm
apphelm.init(api_key=os.environ["APPHELM_API_KEY"], workspace_id="org_…", product_id="prd_…",
             release=os.environ.get("RELEASE"), environment="production", traces_sample_rate=0.2)

MIDDLEWARE = ["apphelm_django.ApphelmMiddleware", *MIDDLEWARE]   # first
APPHELM = {"capture_user": True, "db_spans": True, "query_breadcrumbs": False}   # optional, these are the defaults
# APPHELM = {"capture_user": False}   # don't send the user's id hash
```

- Each request (with a rate set) becomes an `http.server` transaction named by its resolved route pattern
  (`GET /orders/{id}/`), with `http.request.method` and `http.response.status_code`; a 5xx
  response or an exception marks it an error. Incoming `traceparent`/`sentry-trace` is continued.
- Unhandled exceptions (Django's `got_request_exception`, so `Http404` and `PermissionDenied`
  are not reported) are captured with `handled: False`, the request's scope, and the signed-in
  user's primary key as a hash only (`capture_user`, on by default).
- ORM queries become `db` spans (the SQL with placeholders, never the parameters) with
  `db.system` from the database vendor. In a process without requests (a Celery worker using
  the ORM) call `apphelm_django.instrument_db()` once.
- Sync (WSGI) and async (ASGI) Django both work.

### Flask

```py
import os, apphelm, apphelm_flask
from flask import Flask

apphelm.init(api_key=os.environ["APPHELM_API_KEY"], workspace_id="org_…", product_id="prd_…",
             traces_sample_rate=0.2)
app = Flask(__name__)
apphelm_flask.init_app(app)                    # init_app(app, capture_user=False) to skip the user hash
```

Transactions are named by the URL rule (`GET /orders/{order_id}`); unhandled exceptions
(`got_request_exception`) are captured with `handled: False`. With Flask-Login the signed-in
user's id is added as a hash only (`init_app(app, capture_user=False)` to skip).

### FastAPI and Starlette (ASGI)

```py
import os, apphelm
from fastapi import FastAPI
from apphelm_asgi import ApphelmMiddleware

apphelm.init(api_key=os.environ["APPHELM_API_KEY"], workspace_id="org_…", product_id="prd_…",
             traces_sample_rate=0.2)
app = FastAPI()
app.add_middleware(ApphelmMiddleware)          # any ASGI app: app = ApphelmMiddleware(app)
# app.add_middleware(ApphelmMiddleware, capture_user=False)   # don't send the user's id hash
```

Transactions are named by the matched route's path template, router prefixes and mounts
included (`GET /v1/items/{item_id}`). Async and sync endpoints both work. An exception that
escapes the app is captured with `handled: False` and re-raised. With Starlette's
`AuthenticationMiddleware`, `request.user.identity` is added as a hash only (`capture_user=False` to skip).

### Celery

```py
import os, apphelm, apphelm_celery

apphelm.init(api_key=os.environ["APPHELM_API_KEY"], workspace_id="org_…", product_id="prd_…",
             release=os.environ.get("RELEASE"), release_health="process", traces_sample_rate=0.1)
apphelm_celery.instrument()                    # in the web app (publishing) and in the worker

@app.task
@apphelm.monitor("nightly-export", schedule="0 3 * * *")    # optional: cron check-ins for beat tasks
def nightly_export():
    ...
```

- Each task run is a `queue.process` transaction named by the task, with
  `messaging.destination.name` (the queue) and `messaging.message.receive.latency`, in its own
  scope (tagged `celery.task`).
- Publishing a task inside an active span (a web request) records a `queue.publish` span and
  sends `traceparent` in the task headers, so the worker joins the same trace
  (`instrument(propagate_traces=False)` to stop that).
- A failed task is captured with `handled: False`; exceptions in the task's `throws` are not.
  Arguments and results are never recorded.

### requests and httpx

```py
import apphelm_http

apphelm_http.instrument()          # requests and httpx (sync and async), whichever are installed
apphelm_http.uninstrument()        # puts the originals back
# or one at a time: instrument_requests(), instrument_httpx()
```

A call made while a span is active becomes an `http.client` span
(`GET https://api.example.com/v1/orders`) with `http.request.method`,
`http.response.status_code`, `server.address` and `url.full` (no query); a status from 400 up
marks it an error. Every call leaves an `http` breadcrumb (`GET https://host/path 200`), and
`traceparent` is added for hosts in `trace_propagation_hosts` only. No headers, bodies or
query strings are recorded.

### SQLAlchemy

```py
import apphelm_sqlalchemy

apphelm_sqlalchemy.instrument()                 # every Engine (async engines too)
apphelm_sqlalchemy.instrument(engine, query_breadcrumbs=True)   # or one engine, with query crumbs
apphelm_sqlalchemy.uninstrument()
```

Statements run while a span is active become `db` spans: the SQL text as given to the driver
(placeholders, never bound values) and `db.system` from the dialect (`postgresql`, `mysql`,
`sqlite`…). A failed statement marks its span an error.

### Integration tests

`python3 -m pytest` runs everything; each integration's tests skip when its framework is not
installed, so the suite also passes on a bare interpreter.

## Logs

```py
import apphelm

apphelm.init(api_key="ahl_live_…", workspace_id="org_…", product_id="prd_…",
             logs_service="api", logs_min_level="info")       # min level defaults to info
apphelm.logger.info("Order paid", {"order_id": 42})           # or apphelm.logger.info("Order paid", order_id=42)
apphelm.logger.error("Charge failed", {"error": exc})          # exception.type, .message and .stacktrace
orders = apphelm.logger.child({"component": "orders"})        # every line carries component=orders
orders.warn("Slow query", ms=900)
apphelm.flush()                                                # also sends queued lines
```

`apphelm.logger` (or `apphelm_client.logger`, or `ApphelmLogger(api_key=…, product_id=…)` from
`apphelm_logging`) has Node's logger API: `trace`, `debug`, `info`, `warn`, `error`, `fatal`,
`log(level, …)`, `child` and `flush(timeout)`. Inside a transaction or span a line carries its
`traceId` and `spanId`. The message and every string attribute (nested too) are scrubbed before
`before_send_log=` sees the OTLP record, which can change it or return `None` to drop it (a hook
that raises drops the line). Lines are batched (every 2 s or 500 lines, at most 10,000 waiting;
the oldest are dropped past that) and sent as OTLP/JSON to `/api/v1/logs`. A batch refused with
429 or 5xx, or that could not be sent, is tried once more; other refusals drop it. Before
`init`, `apphelm.logger` does nothing. The key needs `logs:write` or `events:write`.

The standard `logging` module, through a handler:

```py
import logging
from apphelm_logging import ApphelmLogHandler

logging.getLogger().addHandler(ApphelmLogHandler(api_key="ahl_live_…", product_id="prd_…", service="worker"))
logging.getLogger(__name__).error("Payment failed", extra={"order_id": 42, "distinct_id": user_id})
```

Records go to AppHelm Logs as OpenTelemetry logs, batched from a background thread (standard
library only). `extra` fields become attributes; `distinct_id`, `session_id` and `trace_id` link a
line to a person, a replay and a trace; `logger.exception` sends the stack. The message, string
attributes and exception are scrubbed of secrets and emails before the handler's `before_send`
sees the record (ids such as `distinct_id` and `session_id` are left alone). Inside an AppHelm
transaction or span a record carries its trace and span ids. The handler shares the logger's
batching and retry. The key needs
`logs:write` or `events:write`. Any OpenTelemetry Python exporter works as well, pointed at
`https://apphelm.dev/api/v1/logs`.

## Cron check-ins

```py
# The whole run: in_progress with a fresh chk_ id, then ok or error with durationMs.
@apphelm.monitor("nightly-export", schedule="0 3 * * *", max_runtime_minutes=60)   # after init
def nightly_export():
    ...

with apphelm_client.monitor("prd_…", "hourly-sync", schedule=60):   # interval in minutes; no init needed
    sync()

# Or by hand.
apphelm_client.check_in("prd_…", monitor="nightly-export", status="ok", duration_ms=5400)
```

`monitor` works as a decorator (sync or `async` functions) and as a context manager (`with` or
`async with`). `schedule` (a crontab string or minutes), `checkin_margin_minutes`,
`max_runtime_minutes` and `name` create or update the monitor. The job's exception is
re-raised; a failed check-in never breaks the job.

## Errors from the client

Failed requests raise `ApphelmError` with `status` and `code`. Flag checks (`is_enabled`,
`get_feature_flag`) never raise for a failed request; they return `False`.

## Licence

Proprietary. See `LICENSE`: use requires an active Apphelm service agreement.

Releasing this package: `docs/SDK-RELEASING.md` in the Apphelm repository.
