log-foundry 0.5.1.dev11__tar.gz → 0.6.1.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/PKG-INFO +1 -1
- log_foundry-0.6.1.dev1/pyproject.toml +162 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/context.py +9 -6
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/decorator.py +5 -5
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/ids.py +6 -6
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/model.py +5 -5
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sanitize.py +3 -3
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/_chunk.py +4 -2
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/_time.py +1 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/callback.py +4 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/file.py +1 -3
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/filtering.py +5 -2
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/firehose.py +3 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/http.py +8 -2
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/kinesis.py +6 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/logging_sink.py +1 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/logstash.py +3 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/multi.py +3 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/redis.py +1 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/sentry.py +3 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/transform.py +5 -2
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/util.py +1 -1
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/worker.py +5 -4
- log_foundry-0.5.1.dev11/pyproject.toml +0 -99
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/LICENSE +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/README.md +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/__init__.py +13 -13
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/api.py +3 -3
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/config.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/console.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.5.1.dev11 → log_foundry-0.6.1.dev1}/src/log_foundry/sinks/syslog.py +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# Distribution name on PyPI; the import name matches it (`log_foundry`, see src/).
|
|
3
|
+
# The project was originally "log-forge", which PyPI rejects as too similar to the unrelated,
|
|
4
|
+
# pre-existing "logforge" project — its similarity check collapses separators.
|
|
5
|
+
name = "log-foundry"
|
|
6
|
+
# The version is derived from Git tags at build time (poetry-dynamic-versioning).
|
|
7
|
+
# Do not add a literal `version` key back here.
|
|
8
|
+
dynamic = []
|
|
9
|
+
description = "Generate logs for your console and JSON events for downstream consumption."
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Andrew Griffith" }
|
|
12
|
+
]
|
|
13
|
+
license = "MIT"
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
# 3.12 is the floor because that is the newest runtime a large class of deployed AWS Lambda
|
|
16
|
+
# fleets is on, and nothing in the library needs 3.13 (SPEC-013 FR-001). CI runs the full gate
|
|
17
|
+
# on 3.12 *and* 3.13 — the declaration follows the evidence.
|
|
18
|
+
requires-python = ">=3.12"
|
|
19
|
+
dependencies = [
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# Optional features. Install with: pip install log-foundry[aws]
|
|
23
|
+
version = "0.6.1.dev1"
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
|
|
27
|
+
sentry = ["sentry-sdk>=2.0"]
|
|
28
|
+
kafka = ["confluent-kafka>=2.0"]
|
|
29
|
+
redis = ["redis>=5.0"]
|
|
30
|
+
amqp = ["pika>=1.3"]
|
|
31
|
+
nats = ["nats-py>=2.6"]
|
|
32
|
+
gcp-pubsub = ["google-cloud-pubsub>=2.18"]
|
|
33
|
+
azure-eventhubs = ["azure-eventhub>=5.11"]
|
|
34
|
+
# clickhouse-connect caps at Python <3.15; marker scopes it so the unbounded project range resolves.
|
|
35
|
+
clickhouse = ["clickhouse-connect>=0.7; python_version < '3.15'"]
|
|
36
|
+
mongo = ["pymongo>=4.6"]
|
|
37
|
+
postgres = ["psycopg[binary]>=3.1"]
|
|
38
|
+
|
|
39
|
+
[tool.poetry]
|
|
40
|
+
# Poetry still requires this field to exist; it is only a placeholder.
|
|
41
|
+
# The real value is injected from Git tags during the build.
|
|
42
|
+
|
|
43
|
+
# src layout: map the package from the src/ directory
|
|
44
|
+
packages = [
|
|
45
|
+
{ include = "log_foundry", from = "src" },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Non-Python files to include in the package
|
|
49
|
+
include = [
|
|
50
|
+
"src/log_foundry/py.typed", # PEP 561 marker for typed packages
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# Files to exclude from the package
|
|
54
|
+
exclude = [
|
|
55
|
+
"tests/**/*",
|
|
56
|
+
"docs/**/*",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# Development-only dependencies (not shipped to consumers).
|
|
60
|
+
# Install with: poetry install --with dev
|
|
61
|
+
[tool.poetry.group.dev.dependencies]
|
|
62
|
+
pytest = "^9.0"
|
|
63
|
+
pytest-asyncio = "^1.4.0" # for testing async @trace (guide Phase 8)
|
|
64
|
+
pytest-cov = "^7.1.0"
|
|
65
|
+
# Bounded to a minor, not left open. Ruff's DEFAULT rule set is not stable across minors —
|
|
66
|
+
# 0.16.0 widened it from 59 rules to 413 — so an open constraint lets a release change what
|
|
67
|
+
# CI enforces. `[tool.ruff.lint] select` below is the other half: the bound makes the change
|
|
68
|
+
# deliberate, the select makes the set ours.
|
|
69
|
+
ruff = ">=0.16,<0.17"
|
|
70
|
+
mypy = "^1.10"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
[tool.poetry-dynamic-versioning]
|
|
74
|
+
enable = false
|
|
75
|
+
vcs = "git"
|
|
76
|
+
style = "pep440"
|
|
77
|
+
bump = true # development builds sort after the last release (e.g. 0.0.2.devN)
|
|
78
|
+
metadata = false # drop the +<hash> local segment so PyPI accepts the upload
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
[build-system]
|
|
82
|
+
requires = ["poetry-core>=2.0.0,<3.0.0", "poetry-dynamic-versioning>=1.4.0,<2.0.0"]
|
|
83
|
+
build-backend = "poetry_dynamic_versioning.backend"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
[tool.pytest.ini_options]
|
|
87
|
+
testpaths = ["tests"]
|
|
88
|
+
pythonpath = ["src"] # src layout: make `import log_foundry` work without an install
|
|
89
|
+
asyncio_mode = "auto" # async test functions run without an explicit marker
|
|
90
|
+
addopts = "--strict-markers"
|
|
91
|
+
|
|
92
|
+
[tool.ruff]
|
|
93
|
+
src = ["src", "tests"]
|
|
94
|
+
line-length = 100
|
|
95
|
+
# Deliberately no `target-version`: ruff infers it from `requires-python`, so the two can never
|
|
96
|
+
# drift. If one is ever added it must match the floor above.
|
|
97
|
+
|
|
98
|
+
[tool.ruff.lint]
|
|
99
|
+
# THE RULE SET IS CHOSEN HERE, NOT INHERITED. Ruff's default set is not a stable contract:
|
|
100
|
+
# 0.16.0 widened it from 59 rules to 413. Selecting explicitly means a ruff release can add
|
|
101
|
+
# rules *inside* a family we asked for, but cannot decide which families CI enforces. The
|
|
102
|
+
# `>=0.16,<0.17` bound on the dev dependency is the other half of that.
|
|
103
|
+
select = [
|
|
104
|
+
"F", # pyflakes
|
|
105
|
+
"E4", "E7", "E9", # pycodestyle errors. NOT E5: `line-length` above is advisory
|
|
106
|
+
# today (it feeds `ruff format`, which this repo does not run) and
|
|
107
|
+
# 52 lines in src+tests exceed it. Enforcing it is 52 reflows and
|
|
108
|
+
# its own decision.
|
|
109
|
+
"W",
|
|
110
|
+
"I", # import sorting
|
|
111
|
+
"B", # bugbear
|
|
112
|
+
"UP", # pyupgrade — the floor is 3.12, keep the syntax current
|
|
113
|
+
"SIM", "C4", "PIE", "RET", "FURB", "PERF",
|
|
114
|
+
"RUF",
|
|
115
|
+
"S", # bandit
|
|
116
|
+
"BLE", "TRY", # exception hygiene — the decorator's contract is about exceptions
|
|
117
|
+
"LOG", "G",
|
|
118
|
+
"DTZ", # naive datetimes: this library stamps every event's timestamp
|
|
119
|
+
"ASYNC", # @trace is async-aware (SPEC-003)
|
|
120
|
+
"PYI", "YTT", "FLY", "INT", "TC", "FA", "PGH", "EXE", "ISC",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
# Each entry is a decision with its reason, not a backlog.
|
|
124
|
+
ignore = [
|
|
125
|
+
"BLE001", # THE central one. `except Exception` in a sink is the architecture, not a
|
|
126
|
+
# slip: a broken destination must degrade logging and nothing more, and the
|
|
127
|
+
# decorator must never raise into the caller (arch §4). Eleven of these, all
|
|
128
|
+
# in sinks/ and worker.py, all load-bearing.
|
|
129
|
+
"TRY003", # messages are written at the raise site; a class per message would add names
|
|
130
|
+
# nothing reads.
|
|
131
|
+
"TRY300", # "return in else" reads worse than the early return this code uses.
|
|
132
|
+
"TRY400", # this library IS the logger; it writes to stderr deliberately, and
|
|
133
|
+
# logger.exception is not available to it.
|
|
134
|
+
"S110", # try/except/pass is the shape of "logging must never break the caller"
|
|
135
|
+
# (arch §4). Every occurrence carries a written reason.
|
|
136
|
+
"SIM105", # same sites as S110: contextlib.suppress would hide the written reason
|
|
137
|
+
# behind an import.
|
|
138
|
+
"PERF203", # try/except inside a loop is deliberate in the worker's drain: one bad batch
|
|
139
|
+
# must not abort the rest.
|
|
140
|
+
"S608", # postgres.py and sqlite.py build INSERTs around a table name they validate
|
|
141
|
+
# themselves; the values are always parameterised.
|
|
142
|
+
"UP047", # PEP 695 `def f[T](...)`. Valid on the 3.12 floor, but rewriting the public
|
|
143
|
+
# `@trace` decorator's generics is a change to the signature users read, and
|
|
144
|
+
# the TypeVar form is not deprecated. Its own PR if ever.
|
|
145
|
+
"RUF002", "RUF003", # ambiguous-unicode in docstrings/comments: this codebase writes
|
|
146
|
+
# –, ×, → and — on purpose.
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
[tool.ruff.lint.per-file-ignores]
|
|
150
|
+
# Asserts are the test framework; the rest are fixtures that only look like secrets, naive
|
|
151
|
+
# datetimes under test on purpose, and raises inside a `try` that IS the thing under test.
|
|
152
|
+
"tests/**/*.py" = ["S101", "S106", "DTZ001", "TRY301"]
|
|
153
|
+
# FileSink and RotatingFileSink hold a handle open ACROSS calls by design — that is what makes
|
|
154
|
+
# them a sink rather than a per-event open(). A context manager would close it after one emit.
|
|
155
|
+
"src/log_foundry/sinks/file.py" = ["SIM115"]
|
|
156
|
+
|
|
157
|
+
[tool.mypy]
|
|
158
|
+
files = ["src"]
|
|
159
|
+
# Must be the *lowest* supported version, not the newest: mypy checks against this, so a 3.13-only
|
|
160
|
+
# API would type-check clean on a 3.12 floor and fail as an AttributeError in a consumer's runtime.
|
|
161
|
+
python_version = "3.12"
|
|
162
|
+
strict = true
|
|
@@ -24,21 +24,24 @@ if TYPE_CHECKING:
|
|
|
24
24
|
from log_foundry.model import Span
|
|
25
25
|
|
|
26
26
|
__all__ = [
|
|
27
|
+
"current_baggage_header",
|
|
27
28
|
"current_span",
|
|
28
|
-
"
|
|
29
|
-
"
|
|
29
|
+
"current_trace_context",
|
|
30
|
+
"current_traceparent",
|
|
30
31
|
"get_baggage",
|
|
32
|
+
"pop_span",
|
|
33
|
+
"push_span",
|
|
31
34
|
"set_baggage",
|
|
32
|
-
"current_traceparent",
|
|
33
|
-
"current_trace_context",
|
|
34
|
-
"current_baggage_header",
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
_span_stack: contextvars.ContextVar[tuple[Span, ...]] = contextvars.ContextVar(
|
|
38
38
|
"log_foundry_span_stack", default=()
|
|
39
39
|
)
|
|
40
40
|
_baggage: contextvars.ContextVar[dict[str, object]] = contextvars.ContextVar(
|
|
41
|
-
"log_foundry_baggage",
|
|
41
|
+
"log_foundry_baggage",
|
|
42
|
+
default={}, # noqa: B039 - the never-mutate rule in this module's docstring is what
|
|
43
|
+
# makes the shared default safe: `set_baggage` replaces the dict, `get_baggage` is not
|
|
44
|
+
# exported, and both internal readers treat it read-only.
|
|
42
45
|
)
|
|
43
46
|
# An inbound trace context adopted via ``continue_trace`` (SPEC-014), applied by ``_open_span``
|
|
44
47
|
# to the next *root* span. A ContextVar for the same reason as the two above: it is then correct
|
|
@@ -42,7 +42,7 @@ from log_foundry.ids import (
|
|
|
42
42
|
from log_foundry.model import Span, backfill_baggage, end_event, start_event
|
|
43
43
|
from log_foundry.worker import Health, Worker
|
|
44
44
|
|
|
45
|
-
__all__ = ["
|
|
45
|
+
__all__ = ["continue_trace", "trace"]
|
|
46
46
|
|
|
47
47
|
# Bound on how much of a rejected inbound value is echoed into a stderr warning.
|
|
48
48
|
_MAX_REJECTED_ECHO = 64
|
|
@@ -70,7 +70,7 @@ def _open_span(name: str, defaults: dict[str, object] | None) -> Span:
|
|
|
70
70
|
trace_id, parent_span_id = parent.trace_id, parent.span_id
|
|
71
71
|
else:
|
|
72
72
|
adopted = context.get_adopted_context()
|
|
73
|
-
trace_id, parent_span_id = adopted
|
|
73
|
+
trace_id, parent_span_id = adopted or (new_trace_id(), None)
|
|
74
74
|
span = Span(
|
|
75
75
|
trace_id=trace_id,
|
|
76
76
|
span_id=new_span_id(),
|
|
@@ -229,7 +229,7 @@ def _flush_worker(timeout: float | None = 5.0) -> bool:
|
|
|
229
229
|
return True
|
|
230
230
|
try:
|
|
231
231
|
return worker.flush(timeout)
|
|
232
|
-
except Exception: #
|
|
232
|
+
except Exception: # a flush is the call most likely to be made in a
|
|
233
233
|
# `finally`, so the library must never be the reason a caller's function fails. Any
|
|
234
234
|
# failure is reported by the return value instead (FR-003).
|
|
235
235
|
return False
|
|
@@ -311,7 +311,7 @@ def trace(
|
|
|
311
311
|
finally:
|
|
312
312
|
context.pop_span(token)
|
|
313
313
|
|
|
314
|
-
return cast(F, async_wrapper)
|
|
314
|
+
return cast("F", async_wrapper)
|
|
315
315
|
|
|
316
316
|
@functools.wraps(fn)
|
|
317
317
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
@@ -328,6 +328,6 @@ def trace(
|
|
|
328
328
|
finally:
|
|
329
329
|
context.pop_span(token)
|
|
330
330
|
|
|
331
|
-
return cast(F, wrapper)
|
|
331
|
+
return cast("F", wrapper)
|
|
332
332
|
|
|
333
333
|
return decorate(func) if func is not None else decorate
|
|
@@ -17,13 +17,13 @@ import os
|
|
|
17
17
|
import uuid
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
20
|
-
"new_trace_id",
|
|
21
|
-
"new_span_id",
|
|
22
|
-
"new_log_id",
|
|
23
|
-
"parse_traceparent",
|
|
24
20
|
"format_traceparent",
|
|
25
|
-
"is_valid_trace_id",
|
|
26
21
|
"is_valid_span_id",
|
|
22
|
+
"is_valid_trace_id",
|
|
23
|
+
"new_log_id",
|
|
24
|
+
"new_span_id",
|
|
25
|
+
"new_trace_id",
|
|
26
|
+
"parse_traceparent",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
29
|
_HEX = frozenset("0123456789abcdef")
|
|
@@ -91,7 +91,7 @@ def parse_traceparent(value: object) -> tuple[str, str] | None:
|
|
|
91
91
|
version, trace_id, span_id, flags = parts[0], parts[1], parts[2], parts[3]
|
|
92
92
|
if not _is_hex(version, 2) or version == _INVALID_VERSION:
|
|
93
93
|
return None
|
|
94
|
-
if version == _VERSION:
|
|
94
|
+
if version == _VERSION: # noqa: SIM102 - kept nested so the comment below stays attached
|
|
95
95
|
# Version 00 is defined as exactly four fields; a trailing field is malformed, not a
|
|
96
96
|
# forward-compatible extension.
|
|
97
97
|
if len(parts) != 4 or not _is_hex(flags, 2):
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import time
|
|
12
12
|
import traceback
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
|
-
from datetime import
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
15
|
from typing import TYPE_CHECKING
|
|
16
16
|
|
|
17
17
|
from log_foundry.sanitize import sanitize_fields, truncate_str, truncate_tail
|
|
@@ -19,7 +19,7 @@ from log_foundry.sanitize import sanitize_fields, truncate_str, truncate_tail
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from log_foundry.config import Config
|
|
21
21
|
|
|
22
|
-
__all__ = ["Span", "
|
|
22
|
+
__all__ = ["Span", "backfill_baggage", "build_event", "end_event", "start_event"]
|
|
23
23
|
|
|
24
24
|
# Auto-generated span-boundary event messages. Tests assert on contract fields
|
|
25
25
|
# (``status``, ``trace_id``), never on this text — rename freely.
|
|
@@ -52,7 +52,7 @@ class Span:
|
|
|
52
52
|
|
|
53
53
|
def _iso_now() -> str:
|
|
54
54
|
"""Return the current UTC time as ISO-8601 with millisecond precision and a 'Z'."""
|
|
55
|
-
now = datetime.now(
|
|
55
|
+
now = datetime.now(UTC)
|
|
56
56
|
return f"{now:%Y-%m-%dT%H:%M:%S}.{now.microsecond // 1000:03d}Z"
|
|
57
57
|
|
|
58
58
|
|
|
@@ -121,7 +121,7 @@ def _exception_message(exc: BaseException) -> str:
|
|
|
121
121
|
"""
|
|
122
122
|
try:
|
|
123
123
|
return str(exc)
|
|
124
|
-
except Exception: #
|
|
124
|
+
except Exception: # see above; a hostile __str__ must not escape.
|
|
125
125
|
return "<unprintable message>"
|
|
126
126
|
|
|
127
127
|
|
|
@@ -134,7 +134,7 @@ def _exception_stack(exc: BaseException) -> str:
|
|
|
134
134
|
"""
|
|
135
135
|
try:
|
|
136
136
|
return "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
137
|
-
except Exception: #
|
|
137
|
+
except Exception: # same reasoning as _exception_message.
|
|
138
138
|
return f"<unformattable traceback: {type(exc).__name__}>"
|
|
139
139
|
|
|
140
140
|
|
|
@@ -137,7 +137,7 @@ class _Coercer:
|
|
|
137
137
|
"""Coerce one node. Total — never raises, whatever the value does."""
|
|
138
138
|
try:
|
|
139
139
|
return self._dispatch(value, depth)
|
|
140
|
-
except Exception: #
|
|
140
|
+
except Exception: # a hostile __iter__/__str__/__eq__ must not reach the
|
|
141
141
|
return self._placeholder(value) # caller's stack; this is the whole point (FR-001).
|
|
142
142
|
|
|
143
143
|
def _dispatch(self, value: object, depth: int) -> object:
|
|
@@ -246,7 +246,7 @@ class _Coercer:
|
|
|
246
246
|
"""Name the type that could not be coerced, without disclosing the value."""
|
|
247
247
|
try:
|
|
248
248
|
name = type(value).__name__
|
|
249
|
-
except Exception: #
|
|
249
|
+
except Exception: # a pathological metaclass is still not our problem.
|
|
250
250
|
name = "?"
|
|
251
251
|
return f"<unserializable: {name}>"
|
|
252
252
|
|
|
@@ -277,7 +277,7 @@ def sanitize_fields(
|
|
|
277
277
|
# would replace every field value with ``<depth limit>`` and emit a uniformly empty event,
|
|
278
278
|
# which is exactly what FR-006's validation exists to prevent.
|
|
279
279
|
result = coercer.mapping(fields, -1)
|
|
280
|
-
except Exception: #
|
|
280
|
+
except Exception: # belt and braces: `_Coercer.value` is already total, but
|
|
281
281
|
return {}, True # a hostile top-level mapping must not reach the caller either.
|
|
282
282
|
if not isinstance(result, dict):
|
|
283
283
|
# ``fields`` was itself circular — impossible from ``build_event``, which always passes a
|
|
@@ -9,8 +9,10 @@ single request (callers drop oversized items first, counting ``dropped_oversized
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import re
|
|
12
|
-
from
|
|
13
|
-
|
|
12
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable, Iterator
|
|
14
16
|
|
|
15
17
|
__all__ = ["chunk_items", "chunk_list", "valid_identifier"]
|
|
16
18
|
|
|
@@ -7,7 +7,10 @@ and knows nothing about spans or context — that dumbness is what makes sinks s
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
11
14
|
|
|
12
15
|
__all__ = ["CallbackSink"]
|
|
13
16
|
|
|
@@ -147,9 +147,7 @@ class RotatingFileSink:
|
|
|
147
147
|
and self._size + incoming > self._max_bytes
|
|
148
148
|
):
|
|
149
149
|
return True
|
|
150
|
-
|
|
151
|
-
return True
|
|
152
|
-
return False
|
|
150
|
+
return self._next_rollover is not None and time.time() >= self._next_rollover
|
|
153
151
|
|
|
154
152
|
def _rotate(self) -> None:
|
|
155
153
|
"""Close the active file, shift/prune numbered backups, and open a fresh active file."""
|
|
@@ -8,9 +8,12 @@ time; this only reshapes an already-built batch on its way to a sink.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
from log_foundry.sinks.base import Sink
|
|
14
17
|
|
|
15
18
|
__all__ = ["FilteringSink"]
|
|
16
19
|
|
|
@@ -77,7 +77,9 @@ class FirehoseSink:
|
|
|
77
77
|
results = response.get("RequestResponses", [])
|
|
78
78
|
records = [
|
|
79
79
|
record
|
|
80
|
-
|
|
80
|
+
# strict=False states today's behaviour. A short `results` would silently
|
|
81
|
+
# truncate this retry list — see the note in kinesis.py.
|
|
82
|
+
for record, result in zip(records, results, strict=False)
|
|
81
83
|
if result.get("ErrorCode")
|
|
82
84
|
]
|
|
83
85
|
if not records:
|
|
@@ -20,7 +20,10 @@ import time
|
|
|
20
20
|
import urllib.error
|
|
21
21
|
import urllib.request
|
|
22
22
|
from base64 import b64encode
|
|
23
|
-
from typing import
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Callable
|
|
24
27
|
|
|
25
28
|
__all__ = ["HTTPSink", "merge_headers"]
|
|
26
29
|
|
|
@@ -109,7 +112,10 @@ class HTTPSink:
|
|
|
109
112
|
"""
|
|
110
113
|
headers, data = self._prepare(body, content_type, extra_headers)
|
|
111
114
|
for attempt in range(self.max_retries + 1):
|
|
112
|
-
|
|
115
|
+
# Opening `self.url` is this class's entire purpose. The URL is the application's
|
|
116
|
+
# own configured endpoint, never inbound data — an app that can set it can already
|
|
117
|
+
# read its own files.
|
|
118
|
+
request = urllib.request.Request( # noqa: S310
|
|
113
119
|
self.url, data=data, method=self.method, headers=headers
|
|
114
120
|
)
|
|
115
121
|
try:
|
|
@@ -85,7 +85,12 @@ class KinesisSink:
|
|
|
85
85
|
results = response.get("Records", [])
|
|
86
86
|
records = [
|
|
87
87
|
record
|
|
88
|
-
|
|
88
|
+
# strict=False states today's behaviour rather than changing it. Note the
|
|
89
|
+
# latent case it preserves: if `results` came back shorter than `records`
|
|
90
|
+
# (or empty, via the `.get` default above), zip truncates, `records` empties
|
|
91
|
+
# and the batch reports success — the silent-loss shape SPEC-017 went after.
|
|
92
|
+
# Making that raise is a behaviour change, so it belongs in its own spec.
|
|
93
|
+
for record, result in zip(records, results, strict=False)
|
|
89
94
|
if result.get("ErrorCode")
|
|
90
95
|
]
|
|
91
96
|
if not records:
|
|
@@ -96,7 +96,7 @@ class LoggingSink:
|
|
|
96
96
|
fields = event.get("fields")
|
|
97
97
|
if isinstance(fields, dict):
|
|
98
98
|
# Nested payload is lossless even when a flat key collides and is skipped below.
|
|
99
|
-
|
|
99
|
+
record.fields = fields
|
|
100
100
|
for key, value in fields.items():
|
|
101
101
|
# Skip reserved LogRecord attrs, identity keys, and "fields": the sink owns
|
|
102
102
|
# record.fields (the nested payload); a field named "fields" must not overwrite it.
|
|
@@ -54,7 +54,9 @@ class LogstashSink:
|
|
|
54
54
|
if self._http is not None:
|
|
55
55
|
self._http.emit(batch)
|
|
56
56
|
else:
|
|
57
|
-
|
|
57
|
+
# Narrowing for mypy, not a runtime check: __init__ guarantees exactly one of
|
|
58
|
+
# _http/_socket is set, and the branch above covers the other.
|
|
59
|
+
assert self._socket is not None # noqa: S101
|
|
58
60
|
frames = [(json.dumps(event) + "\n").encode("utf-8") for event in batch]
|
|
59
61
|
self._socket.send_all(frames)
|
|
60
62
|
|
|
@@ -17,8 +17,10 @@ has nothing to retry.
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import sys
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from log_foundry.sinks.base import Sink
|
|
22
24
|
|
|
23
25
|
__all__ = ["MultiSink"]
|
|
24
26
|
|
|
@@ -102,7 +102,9 @@ class SentrySink:
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
def _post_envelope(self, event: dict[str, object]) -> None:
|
|
105
|
-
|
|
105
|
+
# Narrowing for mypy, not a runtime check: _http is set in __init__ whenever the
|
|
106
|
+
# transport path that reaches here is in use.
|
|
107
|
+
assert self._http is not None # noqa: S101
|
|
106
108
|
header = {"event_id": uuid.uuid4().hex, "dsn": self._dsn}
|
|
107
109
|
item_header = {"type": "event"}
|
|
108
110
|
body = (
|
|
@@ -9,9 +9,12 @@ example).
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from log_foundry.sinks.base import Sink
|
|
15
18
|
|
|
16
19
|
__all__ = ["TransformSink"]
|
|
17
20
|
|
|
@@ -23,9 +23,10 @@ import queue
|
|
|
23
23
|
import sys
|
|
24
24
|
import threading
|
|
25
25
|
import time
|
|
26
|
-
from typing import NamedTuple, cast
|
|
26
|
+
from typing import TYPE_CHECKING, NamedTuple, cast
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from log_foundry.sinks.base import Sink
|
|
29
30
|
|
|
30
31
|
__all__ = ["Health", "Worker"]
|
|
31
32
|
|
|
@@ -125,7 +126,7 @@ class Worker:
|
|
|
125
126
|
sys.stderr.write(
|
|
126
127
|
f"log-foundry: log queue full, dropped {total} submission(s) so far\n"
|
|
127
128
|
)
|
|
128
|
-
except Exception: #
|
|
129
|
+
except Exception: # submit() runs on the *caller's* thread, so an
|
|
129
130
|
# unwritable stderr (closed fd, broken pipe, daemonized process) would raise
|
|
130
131
|
# straight into the app. A diagnostic about dropped logs must never itself be
|
|
131
132
|
# the reason a decorated function fails. The counter is already recorded.
|
|
@@ -267,7 +268,7 @@ class Worker:
|
|
|
267
268
|
try:
|
|
268
269
|
self.sink.emit(batch)
|
|
269
270
|
return
|
|
270
|
-
except Exception: #
|
|
271
|
+
except Exception: # any sink failure must not kill the worker thread
|
|
271
272
|
if attempt >= self.max_retries:
|
|
272
273
|
# Under the lock so a concurrent health() sees a coherent snapshot rather
|
|
273
274
|
# than a half-updated pair. No deadlock: shutdown() releases before join().
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
# Distribution name on PyPI; the import name matches it (`log_foundry`, see src/).
|
|
3
|
-
# The project was originally "log-forge", which PyPI rejects as too similar to the unrelated,
|
|
4
|
-
# pre-existing "logforge" project — its similarity check collapses separators.
|
|
5
|
-
name = "log-foundry"
|
|
6
|
-
# The version is derived from Git tags at build time (poetry-dynamic-versioning).
|
|
7
|
-
# Do not add a literal `version` key back here.
|
|
8
|
-
dynamic = []
|
|
9
|
-
description = "Generate logs for your console and JSON events for downstream consumption."
|
|
10
|
-
authors = [
|
|
11
|
-
{ name = "Andrew Griffith" }
|
|
12
|
-
]
|
|
13
|
-
license = "MIT"
|
|
14
|
-
readme = "README.md"
|
|
15
|
-
# 3.12 is the floor because that is the newest runtime a large class of deployed AWS Lambda
|
|
16
|
-
# fleets is on, and nothing in the library needs 3.13 (SPEC-013 FR-001). CI runs the full gate
|
|
17
|
-
# on 3.12 *and* 3.13 — the declaration follows the evidence.
|
|
18
|
-
requires-python = ">=3.12"
|
|
19
|
-
dependencies = [
|
|
20
|
-
]
|
|
21
|
-
|
|
22
|
-
# Optional features. Install with: pip install log-foundry[aws]
|
|
23
|
-
version = "0.5.1.dev11"
|
|
24
|
-
|
|
25
|
-
[project.optional-dependencies]
|
|
26
|
-
aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
|
|
27
|
-
sentry = ["sentry-sdk>=2.0"]
|
|
28
|
-
kafka = ["confluent-kafka>=2.0"]
|
|
29
|
-
redis = ["redis>=5.0"]
|
|
30
|
-
amqp = ["pika>=1.3"]
|
|
31
|
-
nats = ["nats-py>=2.6"]
|
|
32
|
-
gcp-pubsub = ["google-cloud-pubsub>=2.18"]
|
|
33
|
-
azure-eventhubs = ["azure-eventhub>=5.11"]
|
|
34
|
-
# clickhouse-connect caps at Python <3.15; marker scopes it so the unbounded project range resolves.
|
|
35
|
-
clickhouse = ["clickhouse-connect>=0.7; python_version < '3.15'"]
|
|
36
|
-
mongo = ["pymongo>=4.6"]
|
|
37
|
-
postgres = ["psycopg[binary]>=3.1"]
|
|
38
|
-
|
|
39
|
-
[tool.poetry]
|
|
40
|
-
# Poetry still requires this field to exist; it is only a placeholder.
|
|
41
|
-
# The real value is injected from Git tags during the build.
|
|
42
|
-
|
|
43
|
-
# src layout: map the package from the src/ directory
|
|
44
|
-
packages = [
|
|
45
|
-
{ include = "log_foundry", from = "src" },
|
|
46
|
-
]
|
|
47
|
-
|
|
48
|
-
# Non-Python files to include in the package
|
|
49
|
-
include = [
|
|
50
|
-
"src/log_foundry/py.typed", # PEP 561 marker for typed packages
|
|
51
|
-
]
|
|
52
|
-
|
|
53
|
-
# Files to exclude from the package
|
|
54
|
-
exclude = [
|
|
55
|
-
"tests/**/*",
|
|
56
|
-
"docs/**/*",
|
|
57
|
-
]
|
|
58
|
-
|
|
59
|
-
# Development-only dependencies (not shipped to consumers).
|
|
60
|
-
# Install with: poetry install --with dev
|
|
61
|
-
[tool.poetry.group.dev.dependencies]
|
|
62
|
-
pytest = "^9.0"
|
|
63
|
-
pytest-asyncio = "^1.4.0" # for testing async @trace (guide Phase 8)
|
|
64
|
-
pytest-cov = "^7.1.0"
|
|
65
|
-
ruff = "^0.5"
|
|
66
|
-
mypy = "^1.10"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
[tool.poetry-dynamic-versioning]
|
|
70
|
-
enable = false
|
|
71
|
-
vcs = "git"
|
|
72
|
-
style = "pep440"
|
|
73
|
-
bump = true # development builds sort after the last release (e.g. 0.0.2.devN)
|
|
74
|
-
metadata = false # drop the +<hash> local segment so PyPI accepts the upload
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
[build-system]
|
|
78
|
-
requires = ["poetry-core>=2.0.0,<3.0.0", "poetry-dynamic-versioning>=1.4.0,<2.0.0"]
|
|
79
|
-
build-backend = "poetry_dynamic_versioning.backend"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
[tool.pytest.ini_options]
|
|
83
|
-
testpaths = ["tests"]
|
|
84
|
-
pythonpath = ["src"] # src layout: make `import log_foundry` work without an install
|
|
85
|
-
asyncio_mode = "auto" # async test functions run without an explicit marker
|
|
86
|
-
addopts = "--strict-markers"
|
|
87
|
-
|
|
88
|
-
[tool.ruff]
|
|
89
|
-
src = ["src", "tests"]
|
|
90
|
-
line-length = 100
|
|
91
|
-
# Deliberately no `target-version`: ruff infers it from `requires-python`, so the two can never
|
|
92
|
-
# drift. If one is ever added it must match the floor above.
|
|
93
|
-
|
|
94
|
-
[tool.mypy]
|
|
95
|
-
files = ["src"]
|
|
96
|
-
# Must be the *lowest* supported version, not the newest: mypy checks against this, so a 3.13-only
|
|
97
|
-
# API would type-check clean on a 3.12 floor and fail as an AttributeError in a consumer's runtime.
|
|
98
|
-
python_version = "3.12"
|
|
99
|
-
strict = true
|
|
File without changes
|
|
File without changes
|
|
@@ -87,22 +87,22 @@ def shutdown() -> None:
|
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
__all__ = [
|
|
90
|
+
"Health",
|
|
91
|
+
"__version__",
|
|
90
92
|
"configure",
|
|
91
|
-
"get_config",
|
|
92
|
-
"trace",
|
|
93
|
-
"debug",
|
|
94
|
-
"info",
|
|
95
|
-
"warning",
|
|
96
|
-
"error",
|
|
97
|
-
"critical",
|
|
98
|
-
"set_baggage",
|
|
99
93
|
"continue_trace",
|
|
100
|
-
"
|
|
101
|
-
"current_trace_context",
|
|
94
|
+
"critical",
|
|
102
95
|
"current_baggage_header",
|
|
96
|
+
"current_trace_context",
|
|
97
|
+
"current_traceparent",
|
|
98
|
+
"debug",
|
|
99
|
+
"error",
|
|
103
100
|
"flush",
|
|
104
|
-
"
|
|
101
|
+
"get_config",
|
|
105
102
|
"health",
|
|
106
|
-
"
|
|
107
|
-
"
|
|
103
|
+
"info",
|
|
104
|
+
"set_baggage",
|
|
105
|
+
"shutdown",
|
|
106
|
+
"trace",
|
|
107
|
+
"warning",
|
|
108
108
|
]
|
|
@@ -20,12 +20,12 @@ from log_foundry.ids import new_span_id, new_trace_id
|
|
|
20
20
|
from log_foundry.model import Span, build_event
|
|
21
21
|
|
|
22
22
|
__all__ = [
|
|
23
|
+
"critical",
|
|
23
24
|
"debug",
|
|
24
|
-
"info",
|
|
25
|
-
"warning",
|
|
26
25
|
"error",
|
|
27
|
-
"
|
|
26
|
+
"info",
|
|
28
27
|
"set_baggage",
|
|
28
|
+
"warning",
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
# One console writer per process (default stream sys.stderr). Configurable overrides are
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|