agent-first-data 0.2.0__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Agent-First Data (AFD) — suffix-driven output formatting and protocol templates for AI agents
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/cmnspore/agent-first-data
@@ -21,7 +21,7 @@ pip install agent-first-data
21
21
 
22
22
  ## API Reference
23
23
 
24
- Total: **9 public APIs** (4 protocol builders + 3 output functions + 1 internal + 1 utility)
24
+ Total: **9 public APIs** + **AFD logging** (4 protocol builders + 3 output functions + 1 internal + 1 utility)
25
25
 
26
26
  ### Protocol Builders (returns dict)
27
27
 
@@ -256,6 +256,132 @@ print(output_plain(data))
256
256
  # api_key=*** cache_ttl=3600s count=42 created_at=2025-02-07T00:00:00.000Z file_size=5.0MB payment=50000000msats price=$99.99 request_timeout=5.0s success_rate=95.5% user_name=alice
257
257
  ```
258
258
 
259
+ ## AFD Logging
260
+
261
+ AFD-compliant structured logging via Python's `logging` module. Every log line is formatted using the library's own `output_json`/`output_plain`/`output_yaml` functions. Span fields are carried via `contextvars` (async-safe), automatically flattened into each log line.
262
+
263
+ ### API
264
+
265
+ ```python
266
+ from agent_first_data import init_logging_json, init_logging_plain, init_logging_yaml
267
+ from agent_first_data.afd_logging import AfdHandler, get_logger, span
268
+
269
+ # Convenience initializers — set up the root logger with AFD output to stdout
270
+ init_logging_json(level="INFO") # Single-line JSONL (secrets redacted, original keys)
271
+ init_logging_plain(level="INFO") # Single-line logfmt (keys stripped, values formatted)
272
+ init_logging_yaml(level="INFO") # Multi-line YAML (keys stripped, values formatted)
273
+
274
+ # Low-level — create a handler for custom logger stacks
275
+ AfdHandler(format="json") # format: "json" | "plain" | "yaml"
276
+
277
+ # Logger with default fields (returns logging.LoggerAdapter)
278
+ get_logger(name, **fields)
279
+
280
+ # Span context manager — adds fields to all log events within the block
281
+ span(**fields)
282
+ ```
283
+
284
+ ### Setup
285
+
286
+ ```python
287
+ from agent_first_data import init_logging_json, init_logging_plain, init_logging_yaml
288
+
289
+ # JSON output for production (one JSONL line per event, secrets redacted)
290
+ init_logging_json("INFO")
291
+
292
+ # Plain logfmt for development (keys stripped, values formatted)
293
+ init_logging_plain("DEBUG")
294
+
295
+ # YAML for detailed inspection (multi-line, keys stripped, values formatted)
296
+ init_logging_yaml("DEBUG")
297
+ ```
298
+
299
+ ### Log Output
300
+
301
+ Standard `logging` calls work unchanged. Output format depends on the init function used.
302
+
303
+ ```python
304
+ import logging
305
+ logger = logging.getLogger("myapp")
306
+
307
+ logger.info("Server started")
308
+ # JSON: {"timestamp_epoch_ms":1739000000000,"message":"Server started","target":"myapp","code":"info"}
309
+ # Plain: code=info message="Server started" target=myapp timestamp_epoch_ms=1739000000000
310
+ # YAML: ---
311
+ # code: "info"
312
+ # message: "Server started"
313
+ # target: "myapp"
314
+ # timestamp_epoch_ms: 1739000000000
315
+
316
+ logger.warning("DNS lookup failed")
317
+ # JSON: {"timestamp_epoch_ms":...,"message":"DNS lookup failed","target":"myapp","code":"warn"}
318
+ ```
319
+
320
+ ### Span Support
321
+
322
+ Use the `span` context manager to add fields to all log events within the block. Spans nest and work with both sync and async code.
323
+
324
+ ```python
325
+ from agent_first_data import span
326
+
327
+ with span(request_id="abc-123"):
328
+ logger.info("Processing")
329
+ # {"timestamp_epoch_ms":...,"message":"Processing","target":"myapp","request_id":"abc-123","code":"info"}
330
+
331
+ with span(step="validate"):
332
+ logger.info("Validating input")
333
+ # {"timestamp_epoch_ms":...,"message":"Validating input","target":"myapp","request_id":"abc-123","step":"validate","code":"info"}
334
+ ```
335
+
336
+ ### Logger with Default Fields
337
+
338
+ Use `get_logger` for per-component fields that appear on every log line:
339
+
340
+ ```python
341
+ from agent_first_data import get_logger
342
+
343
+ logger = get_logger("myapp.auth", component="auth")
344
+ logger.info("Token verified")
345
+ # {"timestamp_epoch_ms":...,"message":"Token verified","target":"myapp.auth","component":"auth","code":"info"}
346
+ ```
347
+
348
+ ### Custom Code Override
349
+
350
+ The `code` field defaults to the log level. Override with an explicit field:
351
+
352
+ ```python
353
+ from agent_first_data import get_logger
354
+
355
+ logger = get_logger("myapp")
356
+ logger.info("Server ready", extra={"code": "startup"})
357
+ # {"timestamp_epoch_ms":...,"message":"Server ready","target":"myapp","code":"startup"}
358
+ ```
359
+
360
+ ### Output Fields
361
+
362
+ Every log line contains:
363
+
364
+ | Field | Type | Description |
365
+ |:------|:-----|:------------|
366
+ | `timestamp_epoch_ms` | number | Unix milliseconds |
367
+ | `message` | string | Log message |
368
+ | `target` | string | Logger name |
369
+ | `code` | string | Level (debug/info/warn/error) or explicit override |
370
+ | *span fields* | any | From `span()` context manager |
371
+ | *event fields* | any | From `extra=` or `get_logger` fields |
372
+
373
+ ### Log Output Formats
374
+
375
+ All three formats use the library's own output functions, so AFD suffix processing applies to log fields too:
376
+
377
+ | Format | Function | Keys | Values | Use case |
378
+ |:-------|:---------|:-----|:-------|:---------|
379
+ | **JSON** | `init_logging_json` | original (with suffix) | raw | production, log aggregation |
380
+ | **Plain** | `init_logging_plain` | stripped | formatted | development, compact scanning |
381
+ | **YAML** | `init_logging_yaml` | stripped | formatted | debugging, detailed inspection |
382
+
383
+ All formats automatically redact `_secret` fields in log output.
384
+
259
385
  ## Output Formats
260
386
 
261
387
  Three output formats for different use cases:
@@ -12,7 +12,7 @@ pip install agent-first-data
12
12
 
13
13
  ## API Reference
14
14
 
15
- Total: **9 public APIs** (4 protocol builders + 3 output functions + 1 internal + 1 utility)
15
+ Total: **9 public APIs** + **AFD logging** (4 protocol builders + 3 output functions + 1 internal + 1 utility)
16
16
 
17
17
  ### Protocol Builders (returns dict)
18
18
 
@@ -247,6 +247,132 @@ print(output_plain(data))
247
247
  # api_key=*** cache_ttl=3600s count=42 created_at=2025-02-07T00:00:00.000Z file_size=5.0MB payment=50000000msats price=$99.99 request_timeout=5.0s success_rate=95.5% user_name=alice
248
248
  ```
249
249
 
250
+ ## AFD Logging
251
+
252
+ AFD-compliant structured logging via Python's `logging` module. Every log line is formatted using the library's own `output_json`/`output_plain`/`output_yaml` functions. Span fields are carried via `contextvars` (async-safe), automatically flattened into each log line.
253
+
254
+ ### API
255
+
256
+ ```python
257
+ from agent_first_data import init_logging_json, init_logging_plain, init_logging_yaml
258
+ from agent_first_data.afd_logging import AfdHandler, get_logger, span
259
+
260
+ # Convenience initializers — set up the root logger with AFD output to stdout
261
+ init_logging_json(level="INFO") # Single-line JSONL (secrets redacted, original keys)
262
+ init_logging_plain(level="INFO") # Single-line logfmt (keys stripped, values formatted)
263
+ init_logging_yaml(level="INFO") # Multi-line YAML (keys stripped, values formatted)
264
+
265
+ # Low-level — create a handler for custom logger stacks
266
+ AfdHandler(format="json") # format: "json" | "plain" | "yaml"
267
+
268
+ # Logger with default fields (returns logging.LoggerAdapter)
269
+ get_logger(name, **fields)
270
+
271
+ # Span context manager — adds fields to all log events within the block
272
+ span(**fields)
273
+ ```
274
+
275
+ ### Setup
276
+
277
+ ```python
278
+ from agent_first_data import init_logging_json, init_logging_plain, init_logging_yaml
279
+
280
+ # JSON output for production (one JSONL line per event, secrets redacted)
281
+ init_logging_json("INFO")
282
+
283
+ # Plain logfmt for development (keys stripped, values formatted)
284
+ init_logging_plain("DEBUG")
285
+
286
+ # YAML for detailed inspection (multi-line, keys stripped, values formatted)
287
+ init_logging_yaml("DEBUG")
288
+ ```
289
+
290
+ ### Log Output
291
+
292
+ Standard `logging` calls work unchanged. Output format depends on the init function used.
293
+
294
+ ```python
295
+ import logging
296
+ logger = logging.getLogger("myapp")
297
+
298
+ logger.info("Server started")
299
+ # JSON: {"timestamp_epoch_ms":1739000000000,"message":"Server started","target":"myapp","code":"info"}
300
+ # Plain: code=info message="Server started" target=myapp timestamp_epoch_ms=1739000000000
301
+ # YAML: ---
302
+ # code: "info"
303
+ # message: "Server started"
304
+ # target: "myapp"
305
+ # timestamp_epoch_ms: 1739000000000
306
+
307
+ logger.warning("DNS lookup failed")
308
+ # JSON: {"timestamp_epoch_ms":...,"message":"DNS lookup failed","target":"myapp","code":"warn"}
309
+ ```
310
+
311
+ ### Span Support
312
+
313
+ Use the `span` context manager to add fields to all log events within the block. Spans nest and work with both sync and async code.
314
+
315
+ ```python
316
+ from agent_first_data import span
317
+
318
+ with span(request_id="abc-123"):
319
+ logger.info("Processing")
320
+ # {"timestamp_epoch_ms":...,"message":"Processing","target":"myapp","request_id":"abc-123","code":"info"}
321
+
322
+ with span(step="validate"):
323
+ logger.info("Validating input")
324
+ # {"timestamp_epoch_ms":...,"message":"Validating input","target":"myapp","request_id":"abc-123","step":"validate","code":"info"}
325
+ ```
326
+
327
+ ### Logger with Default Fields
328
+
329
+ Use `get_logger` for per-component fields that appear on every log line:
330
+
331
+ ```python
332
+ from agent_first_data import get_logger
333
+
334
+ logger = get_logger("myapp.auth", component="auth")
335
+ logger.info("Token verified")
336
+ # {"timestamp_epoch_ms":...,"message":"Token verified","target":"myapp.auth","component":"auth","code":"info"}
337
+ ```
338
+
339
+ ### Custom Code Override
340
+
341
+ The `code` field defaults to the log level. Override with an explicit field:
342
+
343
+ ```python
344
+ from agent_first_data import get_logger
345
+
346
+ logger = get_logger("myapp")
347
+ logger.info("Server ready", extra={"code": "startup"})
348
+ # {"timestamp_epoch_ms":...,"message":"Server ready","target":"myapp","code":"startup"}
349
+ ```
350
+
351
+ ### Output Fields
352
+
353
+ Every log line contains:
354
+
355
+ | Field | Type | Description |
356
+ |:------|:-----|:------------|
357
+ | `timestamp_epoch_ms` | number | Unix milliseconds |
358
+ | `message` | string | Log message |
359
+ | `target` | string | Logger name |
360
+ | `code` | string | Level (debug/info/warn/error) or explicit override |
361
+ | *span fields* | any | From `span()` context manager |
362
+ | *event fields* | any | From `extra=` or `get_logger` fields |
363
+
364
+ ### Log Output Formats
365
+
366
+ All three formats use the library's own output functions, so AFD suffix processing applies to log fields too:
367
+
368
+ | Format | Function | Keys | Values | Use case |
369
+ |:-------|:---------|:-----|:-------|:---------|
370
+ | **JSON** | `init_logging_json` | original (with suffix) | raw | production, log aggregation |
371
+ | **Plain** | `init_logging_plain` | stripped | formatted | development, compact scanning |
372
+ | **YAML** | `init_logging_yaml` | stripped | formatted | debugging, detailed inspection |
373
+
374
+ All formats automatically redact `_secret` fields in log output.
375
+
250
376
  ## Output Formats
251
377
 
252
378
  Three output formats for different use cases:
@@ -12,6 +12,16 @@ from agent_first_data.format import (
12
12
  parse_size,
13
13
  )
14
14
 
15
+ from agent_first_data.afd_logging import (
16
+ AfdHandler,
17
+ AfdJsonHandler,
18
+ init_json as init_logging_json,
19
+ init_plain as init_logging_plain,
20
+ init_yaml as init_logging_yaml,
21
+ get_logger,
22
+ span,
23
+ )
24
+
15
25
  __all__ = [
16
26
  "build_json_startup",
17
27
  "build_json_ok",
@@ -22,4 +32,11 @@ __all__ = [
22
32
  "output_plain",
23
33
  "internal_redact_secrets",
24
34
  "parse_size",
35
+ "AfdHandler",
36
+ "AfdJsonHandler",
37
+ "init_logging_json",
38
+ "init_logging_plain",
39
+ "init_logging_yaml",
40
+ "get_logger",
41
+ "span",
25
42
  ]
@@ -0,0 +1,161 @@
1
+ """AFD-compliant structured logging.
2
+
3
+ Outputs log events using agent-first-data formatting functions:
4
+ - JSON: single-line JSONL via output_json (secrets redacted, original keys)
5
+ - Plain: single-line logfmt via output_plain (keys stripped, values formatted)
6
+ - YAML: multi-line via output_yaml (keys stripped, values formatted)
7
+
8
+ Span fields are carried via contextvars (async-safe).
9
+
10
+ Usage:
11
+ from agent_first_data.afd_logging import init_json, init_plain, init_yaml, span
12
+ import logging
13
+
14
+ init_json("INFO") # or init_plain("INFO") or init_yaml("DEBUG")
15
+ logger = logging.getLogger("myapp")
16
+
17
+ with span(request_id="abc-123"):
18
+ logger.info("Processing")
19
+ """
20
+
21
+ import logging
22
+ import sys
23
+ from contextvars import ContextVar, Token
24
+ from typing import Any
25
+
26
+ from agent_first_data.format import output_json, output_plain, output_yaml
27
+
28
+ _span_fields: ContextVar[dict[str, Any]] = ContextVar("afd_span", default={})
29
+
30
+ _LEVEL_TO_CODE = {
31
+ "CRITICAL": "error",
32
+ "ERROR": "error",
33
+ "WARNING": "warn",
34
+ "WARN": "warn",
35
+ "INFO": "info",
36
+ "DEBUG": "debug",
37
+ "NOTSET": "trace",
38
+ }
39
+
40
+
41
+ class AfdHandler(logging.Handler):
42
+ """Logging handler that outputs AFD-compliant log lines to stdout.
43
+
44
+ Formats output using the library's own output_json/output_plain/output_yaml.
45
+ """
46
+
47
+ def __init__(self, format: str = "json") -> None:
48
+ super().__init__()
49
+ if format not in ("json", "plain", "yaml"):
50
+ raise ValueError(f"Unknown format: {format!r}, expected json/plain/yaml")
51
+ self._format = format
52
+
53
+ def emit(self, record: logging.LogRecord) -> None:
54
+ entry: dict[str, Any] = {
55
+ "timestamp_epoch_ms": int(record.created * 1000),
56
+ "message": record.getMessage(),
57
+ "target": record.name,
58
+ }
59
+
60
+ # Span fields (from contextvars, async-safe)
61
+ span_data = _span_fields.get()
62
+ if span_data:
63
+ entry.update(span_data)
64
+
65
+ # Event fields (passed via extra= in logging calls)
66
+ has_code = False
67
+ extra = getattr(record, "_afd_fields", None)
68
+ if extra:
69
+ for k, v in extra.items():
70
+ if k == "code":
71
+ has_code = True
72
+ entry[k] = v
73
+
74
+ # Default code from level
75
+ if not has_code:
76
+ entry["code"] = _LEVEL_TO_CODE.get(record.levelname, "info")
77
+
78
+ # Format using the library's own output functions
79
+ if self._format == "plain":
80
+ line = output_plain(entry)
81
+ elif self._format == "yaml":
82
+ line = output_yaml(entry)
83
+ else:
84
+ line = output_json(entry)
85
+
86
+ sys.stdout.write(line + "\n")
87
+ sys.stdout.flush()
88
+
89
+
90
+ # Keep old name as alias for backwards compat
91
+ AfdJsonHandler = AfdHandler
92
+
93
+
94
+ class _AfdLoggerAdapter(logging.LoggerAdapter):
95
+ """Logger adapter that passes extra fields to AfdHandler."""
96
+
97
+ def process(self, msg: str, kwargs: Any) -> tuple[str, Any]:
98
+ extra = kwargs.get("extra", {})
99
+ if self.extra:
100
+ merged = {**self.extra, **extra}
101
+ else:
102
+ merged = extra
103
+ kwargs["extra"] = {"_afd_fields": merged}
104
+ return msg, kwargs
105
+
106
+
107
+ def _init_with_format(format: str, level: str = "INFO") -> None:
108
+ handler = AfdHandler(format=format)
109
+ root = logging.getLogger()
110
+ root.handlers = [handler]
111
+ root.setLevel(getattr(logging, level.upper(), logging.INFO))
112
+
113
+
114
+ def init_json(level: str = "INFO") -> None:
115
+ """Initialize the root logger with AFD JSON output to stdout."""
116
+ _init_with_format("json", level)
117
+
118
+
119
+ def init_plain(level: str = "INFO") -> None:
120
+ """Initialize the root logger with AFD plain/logfmt output to stdout."""
121
+ _init_with_format("plain", level)
122
+
123
+
124
+ def init_yaml(level: str = "INFO") -> None:
125
+ """Initialize the root logger with AFD YAML output to stdout."""
126
+ _init_with_format("yaml", level)
127
+
128
+
129
+ def get_logger(name: str, **fields: Any) -> logging.LoggerAdapter:
130
+ """Get a logger with optional default fields.
131
+
132
+ Fields passed here appear on every log line from this logger.
133
+ Use for per-module or per-component fields.
134
+ """
135
+ base = logging.getLogger(name)
136
+ return _AfdLoggerAdapter(base, fields)
137
+
138
+
139
+ class span:
140
+ """Context manager that adds fields to all log events within the block.
141
+
142
+ Spans nest: inner spans inherit and can override outer span fields.
143
+ Works with both sync and async code (uses contextvars).
144
+
145
+ Usage:
146
+ with span(request_id="abc-123", method="GET"):
147
+ logger.info("Handling request")
148
+ """
149
+
150
+ def __init__(self, **fields: Any) -> None:
151
+ self.fields = fields
152
+ self._token: Token[dict[str, Any]] | None = None
153
+
154
+ def __enter__(self) -> "span":
155
+ current = _span_fields.get()
156
+ self._token = _span_fields.set({**current, **self.fields})
157
+ return self
158
+
159
+ def __exit__(self, *_: Any) -> None:
160
+ if self._token is not None:
161
+ _span_fields.reset(self._token)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-first-data
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Agent-First Data (AFD) — suffix-driven output formatting and protocol templates for AI agents
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/cmnspore/agent-first-data
@@ -21,7 +21,7 @@ pip install agent-first-data
21
21
 
22
22
  ## API Reference
23
23
 
24
- Total: **9 public APIs** (4 protocol builders + 3 output functions + 1 internal + 1 utility)
24
+ Total: **9 public APIs** + **AFD logging** (4 protocol builders + 3 output functions + 1 internal + 1 utility)
25
25
 
26
26
  ### Protocol Builders (returns dict)
27
27
 
@@ -256,6 +256,132 @@ print(output_plain(data))
256
256
  # api_key=*** cache_ttl=3600s count=42 created_at=2025-02-07T00:00:00.000Z file_size=5.0MB payment=50000000msats price=$99.99 request_timeout=5.0s success_rate=95.5% user_name=alice
257
257
  ```
258
258
 
259
+ ## AFD Logging
260
+
261
+ AFD-compliant structured logging via Python's `logging` module. Every log line is formatted using the library's own `output_json`/`output_plain`/`output_yaml` functions. Span fields are carried via `contextvars` (async-safe), automatically flattened into each log line.
262
+
263
+ ### API
264
+
265
+ ```python
266
+ from agent_first_data import init_logging_json, init_logging_plain, init_logging_yaml
267
+ from agent_first_data.afd_logging import AfdHandler, get_logger, span
268
+
269
+ # Convenience initializers — set up the root logger with AFD output to stdout
270
+ init_logging_json(level="INFO") # Single-line JSONL (secrets redacted, original keys)
271
+ init_logging_plain(level="INFO") # Single-line logfmt (keys stripped, values formatted)
272
+ init_logging_yaml(level="INFO") # Multi-line YAML (keys stripped, values formatted)
273
+
274
+ # Low-level — create a handler for custom logger stacks
275
+ AfdHandler(format="json") # format: "json" | "plain" | "yaml"
276
+
277
+ # Logger with default fields (returns logging.LoggerAdapter)
278
+ get_logger(name, **fields)
279
+
280
+ # Span context manager — adds fields to all log events within the block
281
+ span(**fields)
282
+ ```
283
+
284
+ ### Setup
285
+
286
+ ```python
287
+ from agent_first_data import init_logging_json, init_logging_plain, init_logging_yaml
288
+
289
+ # JSON output for production (one JSONL line per event, secrets redacted)
290
+ init_logging_json("INFO")
291
+
292
+ # Plain logfmt for development (keys stripped, values formatted)
293
+ init_logging_plain("DEBUG")
294
+
295
+ # YAML for detailed inspection (multi-line, keys stripped, values formatted)
296
+ init_logging_yaml("DEBUG")
297
+ ```
298
+
299
+ ### Log Output
300
+
301
+ Standard `logging` calls work unchanged. Output format depends on the init function used.
302
+
303
+ ```python
304
+ import logging
305
+ logger = logging.getLogger("myapp")
306
+
307
+ logger.info("Server started")
308
+ # JSON: {"timestamp_epoch_ms":1739000000000,"message":"Server started","target":"myapp","code":"info"}
309
+ # Plain: code=info message="Server started" target=myapp timestamp_epoch_ms=1739000000000
310
+ # YAML: ---
311
+ # code: "info"
312
+ # message: "Server started"
313
+ # target: "myapp"
314
+ # timestamp_epoch_ms: 1739000000000
315
+
316
+ logger.warning("DNS lookup failed")
317
+ # JSON: {"timestamp_epoch_ms":...,"message":"DNS lookup failed","target":"myapp","code":"warn"}
318
+ ```
319
+
320
+ ### Span Support
321
+
322
+ Use the `span` context manager to add fields to all log events within the block. Spans nest and work with both sync and async code.
323
+
324
+ ```python
325
+ from agent_first_data import span
326
+
327
+ with span(request_id="abc-123"):
328
+ logger.info("Processing")
329
+ # {"timestamp_epoch_ms":...,"message":"Processing","target":"myapp","request_id":"abc-123","code":"info"}
330
+
331
+ with span(step="validate"):
332
+ logger.info("Validating input")
333
+ # {"timestamp_epoch_ms":...,"message":"Validating input","target":"myapp","request_id":"abc-123","step":"validate","code":"info"}
334
+ ```
335
+
336
+ ### Logger with Default Fields
337
+
338
+ Use `get_logger` for per-component fields that appear on every log line:
339
+
340
+ ```python
341
+ from agent_first_data import get_logger
342
+
343
+ logger = get_logger("myapp.auth", component="auth")
344
+ logger.info("Token verified")
345
+ # {"timestamp_epoch_ms":...,"message":"Token verified","target":"myapp.auth","component":"auth","code":"info"}
346
+ ```
347
+
348
+ ### Custom Code Override
349
+
350
+ The `code` field defaults to the log level. Override with an explicit field:
351
+
352
+ ```python
353
+ from agent_first_data import get_logger
354
+
355
+ logger = get_logger("myapp")
356
+ logger.info("Server ready", extra={"code": "startup"})
357
+ # {"timestamp_epoch_ms":...,"message":"Server ready","target":"myapp","code":"startup"}
358
+ ```
359
+
360
+ ### Output Fields
361
+
362
+ Every log line contains:
363
+
364
+ | Field | Type | Description |
365
+ |:------|:-----|:------------|
366
+ | `timestamp_epoch_ms` | number | Unix milliseconds |
367
+ | `message` | string | Log message |
368
+ | `target` | string | Logger name |
369
+ | `code` | string | Level (debug/info/warn/error) or explicit override |
370
+ | *span fields* | any | From `span()` context manager |
371
+ | *event fields* | any | From `extra=` or `get_logger` fields |
372
+
373
+ ### Log Output Formats
374
+
375
+ All three formats use the library's own output functions, so AFD suffix processing applies to log fields too:
376
+
377
+ | Format | Function | Keys | Values | Use case |
378
+ |:-------|:---------|:-----|:-------|:---------|
379
+ | **JSON** | `init_logging_json` | original (with suffix) | raw | production, log aggregation |
380
+ | **Plain** | `init_logging_plain` | stripped | formatted | development, compact scanning |
381
+ | **YAML** | `init_logging_yaml` | stripped | formatted | debugging, detailed inspection |
382
+
383
+ All formats automatically redact `_secret` fields in log output.
384
+
259
385
  ## Output Formats
260
386
 
261
387
  Three output formats for different use cases:
@@ -1,9 +1,11 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  agent_first_data/__init__.py
4
+ agent_first_data/afd_logging.py
4
5
  agent_first_data/format.py
5
6
  agent_first_data.egg-info/PKG-INFO
6
7
  agent_first_data.egg-info/SOURCES.txt
7
8
  agent_first_data.egg-info/dependency_links.txt
8
9
  agent_first_data.egg-info/top_level.txt
10
+ tests/test_afd_logging.py
9
11
  tests/test_format.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-first-data"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Agent-First Data (AFD) — suffix-driven output formatting and protocol templates for AI agents"
5
5
  license = "MIT"
6
6
  readme = "README.md"
@@ -0,0 +1,173 @@
1
+ """Tests for AFD logging module."""
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+ from io import StringIO
7
+ from unittest.mock import patch
8
+
9
+ from agent_first_data.afd_logging import AfdHandler, AfdJsonHandler, init_json, init_plain, init_yaml, span, get_logger
10
+
11
+
12
+ def capture_log(fn):
13
+ """Run fn and return the parsed JSON log line."""
14
+ buf = StringIO()
15
+ with patch("sys.stdout", buf):
16
+ fn()
17
+ line = buf.getvalue().strip()
18
+ assert line, "No log output captured"
19
+ return json.loads(line)
20
+
21
+
22
+ def make_logger(name="test"):
23
+ """Create a fresh logger with AfdJsonHandler."""
24
+ logger = logging.getLogger(name)
25
+ logger.handlers = [AfdJsonHandler()]
26
+ logger.setLevel(logging.DEBUG)
27
+ return logger
28
+
29
+
30
+ class TestBasicFields:
31
+ def test_info_message(self):
32
+ logger = make_logger("test_basic")
33
+ m = capture_log(lambda: logger.info("hello world"))
34
+ assert m["message"] == "hello world"
35
+ assert m["code"] == "info"
36
+ assert "timestamp_epoch_ms" in m
37
+ assert m["target"] == "test_basic"
38
+
39
+ def test_warning_code(self):
40
+ logger = make_logger("test_warn")
41
+ m = capture_log(lambda: logger.warning("something wrong"))
42
+ assert m["code"] == "warn"
43
+
44
+ def test_error_code(self):
45
+ logger = make_logger("test_error")
46
+ m = capture_log(lambda: logger.error("failure"))
47
+ assert m["code"] == "error"
48
+
49
+ def test_debug_code(self):
50
+ logger = make_logger("test_debug")
51
+ m = capture_log(lambda: logger.debug("verbose"))
52
+ assert m["code"] == "debug"
53
+
54
+
55
+ class TestSpan:
56
+ def test_span_adds_fields(self):
57
+ logger = make_logger("test_span")
58
+
59
+ def run():
60
+ with span(request_id="abc-123"):
61
+ logger.info("processing")
62
+
63
+ m = capture_log(run)
64
+ assert m["request_id"] == "abc-123"
65
+ assert m["message"] == "processing"
66
+
67
+ def test_nested_spans(self):
68
+ logger = make_logger("test_nested")
69
+
70
+ def run():
71
+ with span(request_id="outer"):
72
+ with span(step="inner"):
73
+ logger.info("nested")
74
+
75
+ m = capture_log(run)
76
+ assert m["request_id"] == "outer"
77
+ assert m["step"] == "inner"
78
+
79
+ def test_inner_span_overrides_parent(self):
80
+ logger = make_logger("test_override")
81
+
82
+ def run():
83
+ with span(source="parent"):
84
+ with span(source="child"):
85
+ logger.info("test")
86
+
87
+ m = capture_log(run)
88
+ assert m["source"] == "child"
89
+
90
+ def test_span_fields_removed_after_exit(self):
91
+ logger = make_logger("test_exit")
92
+ buf = StringIO()
93
+
94
+ with patch("sys.stdout", buf):
95
+ with span(request_id="temp"):
96
+ logger.info("inside")
97
+ buf2 = StringIO()
98
+
99
+ with patch("sys.stdout", buf2):
100
+ logger.info("outside")
101
+
102
+ outside = json.loads(buf2.getvalue().strip())
103
+ assert "request_id" not in outside
104
+
105
+
106
+ class TestCodeOverride:
107
+ def test_explicit_code(self):
108
+ logger = make_logger("test_code")
109
+ adapter = get_logger("test_code")
110
+
111
+ m = capture_log(lambda: adapter.info("ready", extra={"code": "startup"}))
112
+ assert m["code"] == "startup"
113
+
114
+
115
+ class TestGetLogger:
116
+ def test_default_fields(self):
117
+ # Ensure root logger has AfdJsonHandler
118
+ root = logging.getLogger()
119
+ root.handlers = [AfdJsonHandler()]
120
+ root.setLevel(logging.DEBUG)
121
+
122
+ adapter = get_logger("test_adapter", component="myservice")
123
+
124
+ m = capture_log(lambda: adapter.info("event"))
125
+ assert m["component"] == "myservice"
126
+ assert m["message"] == "event"
127
+
128
+
129
+ def capture_raw(fn):
130
+ """Run fn and return the raw output string."""
131
+ buf = StringIO()
132
+ with patch("sys.stdout", buf):
133
+ fn()
134
+ return buf.getvalue()
135
+
136
+
137
+ class TestPlainFormat:
138
+ def test_plain_output(self):
139
+ logger = logging.getLogger("test_plain")
140
+ logger.handlers = [AfdHandler(format="plain")]
141
+ logger.setLevel(logging.DEBUG)
142
+
143
+ output = capture_raw(lambda: logger.info("hello"))
144
+ # Plain format is single-line logfmt
145
+ assert "message=" in output
146
+ assert "code=info" in output
147
+
148
+ def test_init_plain(self):
149
+ buf = StringIO()
150
+ with patch("sys.stdout", buf):
151
+ init_plain("DEBUG")
152
+ logging.getLogger("test_init_plain").info("test")
153
+ output = buf.getvalue()
154
+ assert "message=" in output
155
+
156
+
157
+ class TestYamlFormat:
158
+ def test_yaml_output(self):
159
+ logger = logging.getLogger("test_yaml")
160
+ logger.handlers = [AfdHandler(format="yaml")]
161
+ logger.setLevel(logging.DEBUG)
162
+
163
+ output = capture_raw(lambda: logger.info("hello"))
164
+ # YAML format starts with ---
165
+ assert output.startswith("---")
166
+
167
+ def test_init_yaml(self):
168
+ buf = StringIO()
169
+ with patch("sys.stdout", buf):
170
+ init_yaml("DEBUG")
171
+ logging.getLogger("test_init_yaml").info("test")
172
+ output = buf.getvalue()
173
+ assert output.startswith("---")