Metadata-Version: 2.4
Name: gs-emf
Version: 0.1.0
Summary: Print one JSON line; get a Prometheus metric. Embedded metrics for stdout-only environments.
Project-URL: Homepage, https://github.com/DawnBreather/gs-emf
Project-URL: Source, https://github.com/DawnBreather/gs-emf
Project-URL: Issues, https://github.com/DawnBreather/gs-emf/issues
Author-email: Slava Kim <dawnbreather@gmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: emf,metrics,observability,prometheus,structured-logging
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# gs-emf

Print one JSON line; get a metric.

```python
from gs_emf import emit

emit("order_paid", amount_kgs=1450.50, channel="web")
```

That is the whole setup. No port to open, no credential, no exporter, no merge request. The line goes to stdout, your log agent already ships stdout, and you can aggregate it immediately:

```
_msg:"order_paid" | stats by (channel) sum(amount_kgs)
```

## Install

```bash
pip install gs-emf
```

No dependencies. Python 3.11+.

## Why this exists

A short-lived job cannot be scraped: it is gone before anything polls it. A
service without an HTTP port has nowhere to put `/metrics`. Wrapping either in an
exporter means a port, a credential and a network path, per service.

Every process already writes to stdout, and something already collects it. So the
line carries the numbers.

## Two levels

### `emit()` — exploring

Free-form. Anything numeric becomes a value, anything else a dimension:

```python
from gs_emf import emit

emit("cache_miss", key="user:42", took_ms=12)
```

Queryable in your log store immediately. Nothing to declare, nothing to register.

### `Metric` — when you want an alert

Declare it once and the library checks each call:

```python
from gs_emf import Metric

ORDER_PAID = Metric(
    "order_paid",
    count=["orders"],        # the event happened
    money=["amount_kgs"],    # summed, not counted
    dims=["channel"],        # which fields may become labels
)

ORDER_PAID.emit(orders=1, amount_kgs=1450.50, channel="web")
```

`count` and `money` are different words because they compile to different things.
A counter ignores its field's value: declaring revenue as `count` would report
"three orders" instead of "700 in revenue". `money` sums.

What the declaration catches, on your laptop:

```python
#!skip
ORDER_PAID.emit(orders=1, amont_kgs=1450, channel="web")
# ValueError: metric 'order_paid' was not declared with 'amont_kgs' —
#             did you mean 'amount_kgs'?

ORDER_PAID.emit(orders=1, channel="web")
# ValueError: metric 'order_paid' is missing ['amount_kgs']. Every declared value
#             field must be present in every event: the decoder drops an event
#             with a missing field and that costs this event's OTHER metrics too
```

That second one is the important one. A missing field does not lose one metric, it
loses the whole event — so an order emitted without its amount also stops counting
as an order.

### `scope()` — several numbers, one line

```python
import time
from gs_emf import scope

start = time.perf_counter()
with scope("checkout") as m:
    m.count("items", 3)
    m.money("cart_kgs", 1450.50)
    m.ms("db_ms", (time.perf_counter() - start) * 1000)
    m.dim(channel="web")
```

One line out, not three. It also emits when the block raises — the metrics of a
failed request are the ones you want while diagnosing it — and records
`outcome`, `error_type` and `duration_ms` for you.

## Generating the registry entry

If your fleet materialises these into real series from a registry, do not write
the entry by hand:

```bash
gs-emf registry --module app/metrics.py --owner my-namespace
gs-emf check    --module app/metrics.py --owner my-namespace \
                --registry path/to/metrics.yaml     # for CI
```

`check` exits non-zero and names the command that fixes it, so it is safe to put
in a pipeline.

If you operate the collector rather than the service, the same registry renders
the decoder that turns these lines into series:

```bash
gs-emf render --registry path/to/metrics.yaml --cluster my-cluster
```

It emits Vector `transforms` and a `sink` — one `log_to_metric` per field, because
a transform holding several metrics loses ALL of them for an event missing any one
field. Summed fields (`money`, `ms`) get `increment_by_value`; counted ones must
not, since a counter ignores its field's value. `namespace` and `cluster` are added
to every metric for you. The sink type is left as `__SINK__` unless you pass
`--sink-type`, so an unsubstituted render is obvious rather than quietly valid.

## Three things you cannot configure

**It always flushes.** Python buffers stdout when it is not a terminal, and a job
that exits takes the buffer with it. There is no parameter for this.

**It will never raise in production.** A payment must not fail because of a
metric. On a terminal it does raise — a metric that silently does nothing while
you are writing it is the worse failure.

**No dependencies, ever.** stdlib only.

## What it deliberately does not do

No in-process aggregation or batching: a killed job loses the buffer, and that is
how jobs die. No background thread: a job that lives one second never reaches the
flush. No network, no port. No logger configuration — it writes the line itself,
so nothing upstream can swallow your events.

## Values it refuses, and why

| You pass | What happens |
|---|---|
| `Decimal("1450.50")` | converted to `1450.5` — `json.dumps` cannot serialise a Decimal |
| `timedelta(milliseconds=42)` | converted to `42.0` milliseconds |
| `None` | field refused; a null makes the decoder drop the whole event |
| `NaN`, `Inf` | field refused; the decoder drops these with no error at all |
| `True` | refused — emit `1` and put the flag in a dimension |
| `"200.50"` | accepted as `200.5` |

The rest of the event is still emitted when one field is refused. That is the
point: one bad value must not cost you the others.

## License

Apache-2.0
