Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/reference/advanced/artifacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Artifacts

Artifacts let you attach a **binary blob** — an image, audio clip, PDF, or a large JSON
payload — to a span. The blob is stored separately from your telemetry, so it is not
subject to span attribute size limits and does not bloat your traces. The span itself
carries only a small reference; Logfire uploads the blob out of band.

## Logging an artifact

Wrap your data in `logfire.Artifact` and pass it as a span or log attribute:

```python skip="true"
import logfire

logfire.configure()

with open('chart.png', 'rb') as f:
image_bytes = f.read()

logfire.info('chart generated', chart=logfire.Artifact(image_bytes, content_type='image/png'))
```

The `chart` argument renders as an image preview on the trace in the Logfire UI, with a
download link — not as a wall of base64.

### From a file or a file handle

`Artifact.from_file` reads a path lazily (no need to load the bytes yourself), and
`Artifact.from_file_handle` accepts any open binary handle, including temporary files:

```python skip="true"
import logfire

logfire.configure()

# From a path — the content type is guessed from the extension.
logfire.info('report ready', report=logfire.Artifact.from_file('report.pdf'))

# From an open binary handle.
with open('clip.mp3', 'rb') as handle:
logfire.info('audio processed', clip=logfire.Artifact.from_file_handle(handle))
```

## When the upload happens

Each artifact chooses when its bytes are uploaded, via the `upload` argument:

- **`background`** (the default) — the upload is handed to a background thread and the
logging call never blocks. If uploads cannot keep up, queued artifacts are dropped
with a warning rather than stalling your program.
- **`sync`** — the upload runs inline; the logging call returns only once the blob is
stored. Use this when you need delivery guaranteed, or when you want to free the
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delivery is far from guaranteed with sync, it still just silently swallows request exceptions without retrying.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should make the guarantee stronger (error) or make the docstrings match current impl?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we eventually want a stronger guarantee, but it doesn't have to be in the first pass. until then, the docs should be accurate.

source bytes/file immediately afterwards.

```python skip="true"
import logfire

logfire.configure()

# Block until this artifact is uploaded.
logfire.info('critical input', data=logfire.Artifact(payload, upload='sync'))
```

## How it works

Artifacts are **content-addressed**: an artifact's identity is the sha256 of its bytes.
The same blob logged repeatedly — within a project — is uploaded and stored only once.
The reference embedded in the span looks like:

```json
{
"type": "logfire.artifact",
"sha256": "9f86d0818...",
"filename": "chart.png",
"content_type": "image/png",
"size_bytes": 28421
}
```

The blob never travels through the telemetry pipeline. On SaaS the SDK uploads it
directly to object storage via a signed URL; self-hosted deployments route it through
the Logfire backend.

## Viewing artifacts

Open a span in the Logfire UI: any artifact-valued argument renders inline — images,
audio, video, and PDFs as previews, everything else as a download link — alongside its
filename, content type, and size.
21 changes: 16 additions & 5 deletions logfire-api/logfire_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

import importlib
import sys
from collections.abc import Sequence
from contextlib import contextmanager, nullcontext
from typing import Any, ContextManager, Literal, TYPE_CHECKING, Sequence
from typing import TYPE_CHECKING, Any, ContextManager, Literal
from unittest.mock import MagicMock


try:
logfire_module = importlib.import_module('logfire')
sys.modules[__name__] = logfire_module
Expand Down Expand Up @@ -85,7 +85,7 @@ def end_time(self):
def parent(self):
return None

def set_attribute(self, key: str, value: Any) -> None: ... # pragma: no cover
def set_attribute(self, key: str, value: Any) -> None: ... # pragma: no cover

class Logfire:
def __getattr__(self, attr):
Expand Down Expand Up @@ -208,7 +208,6 @@ def url_from_eval(self, *args, **kwargs) -> str | None: ...

def shutdown(self, *args, **kwargs) -> None: ...


DEFAULT_LOGFIRE_INSTANCE = Logfire()
span = DEFAULT_LOGFIRE_INSTANCE.span
log = DEFAULT_LOGFIRE_INSTANCE.log
Expand Down Expand Up @@ -293,7 +292,6 @@ def __init__(self, *args, **kwargs) -> None: ...
class MetricsOptions:
def __init__(self, *args, **kwargs) -> None: ...


class PydanticPlugin:
def __init__(self, *args, **kwargs) -> None: ...

Expand All @@ -309,6 +307,19 @@ def __init__(self, *args, **kwargs) -> None: ...
class LogfireLoggingHandler:
def __init__(self, *args, **kwargs) -> None: ...

UploadMode = Literal['sync', 'background']

class Artifact:
def __init__(self, *args, **kwargs) -> None: ...

@classmethod
def from_file(cls, *args, **kwargs) -> Artifact:
return cls()

@classmethod
def from_file_handle(cls, *args, **kwargs) -> Artifact:
return cls()

def logfire_info() -> str:
"""Show versions of logfire, OS and related packages."""
return 'logfire_info() is not implement by logfire-api'
Expand Down
24 changes: 18 additions & 6 deletions logfire-api/logfire_api/__init__.pyi
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
from typing import Any

from logfire.propagate import attach_context as attach_context, get_context as get_context
from logfire.sampling import SamplingOptions as SamplingOptions

from . import variables as variables
from ._internal.artifacts import Artifact as Artifact, UploadMode as UploadMode
from ._internal.auto_trace import AutoTraceModule as AutoTraceModule
from ._internal.auto_trace.rewrite_ast import no_auto_trace as no_auto_trace
from ._internal.baggage import get_baggage as get_baggage, set_baggage as set_baggage
from ._internal.cli import logfire_info as logfire_info
from ._internal.config import AdvancedOptions as AdvancedOptions, CodeSource as CodeSource, ConsoleOptions as ConsoleOptions, LocalVariablesOptions as LocalVariablesOptions, MetricsOptions as MetricsOptions, PydanticPlugin as PydanticPlugin, VariablesOptions as VariablesOptions, configure as configure
from ._internal.config import (
AdvancedOptions as AdvancedOptions,
CodeSource as CodeSource,
ConsoleOptions as ConsoleOptions,
LocalVariablesOptions as LocalVariablesOptions,
MetricsOptions as MetricsOptions,
PydanticPlugin as PydanticPlugin,
VariablesOptions as VariablesOptions,
configure as configure,
)
from ._internal.constants import LevelName as LevelName
from ._internal.main import Logfire as Logfire, LogfireSpan as LogfireSpan
from ._internal.scrubbing import ScrubMatch as ScrubMatch, ScrubbingOptions as ScrubbingOptions
from ._internal.scrubbing import ScrubbingOptions as ScrubbingOptions, ScrubMatch as ScrubMatch
from ._internal.stack_info import add_non_user_code_prefix as add_non_user_code_prefix
from ._internal.utils import suppress_instrumentation as suppress_instrumentation
from .integrations.logging import LogfireLoggingHandler as LogfireLoggingHandler
from .integrations.structlog import LogfireProcessor as StructlogProcessor
from .version import VERSION as VERSION
from logfire.propagate import attach_context as attach_context, get_context as get_context
from logfire.sampling import SamplingOptions as SamplingOptions
from typing import Any

__all__ = ['Logfire', 'LogfireSpan', 'LevelName', 'AdvancedOptions', 'ConsoleOptions', 'CodeSource', 'PydanticPlugin', 'configure', 'span', 'instrument', 'log', 'trace', 'debug', 'notice', 'info', 'warn', 'warning', 'error', 'exception', 'fatal', 'force_flush', 'log_slow_async_callbacks', 'install_auto_tracing', 'instrument_asgi', 'instrument_wsgi', 'instrument_pydantic', 'instrument_pydantic_ai', 'instrument_fastapi', 'instrument_openai', 'instrument_openai_agents', 'instrument_anthropic', 'instrument_google_genai', 'instrument_litellm', 'instrument_dspy', 'instrument_print', 'instrument_asyncpg', 'instrument_httpx', 'instrument_celery', 'instrument_requests', 'instrument_psycopg', 'instrument_django', 'instrument_flask', 'instrument_starlette', 'instrument_aiohttp_client', 'instrument_aiohttp_server', 'instrument_sqlalchemy', 'instrument_sqlite3', 'instrument_aws_lambda', 'instrument_redis', 'instrument_pymongo', 'instrument_mysql', 'instrument_surrealdb', 'instrument_system_metrics', 'instrument_mcp', 'instrument_claude_agent_sdk', 'AutoTraceModule', 'with_tags', 'with_settings', 'suppress_scopes', 'shutdown', 'no_auto_trace', 'ScrubMatch', 'ScrubbingOptions', 'VERSION', 'add_non_user_code_prefix', 'suppress_instrumentation', 'StructlogProcessor', 'LogfireLoggingHandler', 'loguru_handler', 'SamplingOptions', 'MetricsOptions', 'VariablesOptions', 'LocalVariablesOptions', 'variables', 'var', 'variables_clear', 'variables_get', 'variables_push', 'variables_push_types', 'variables_validate', 'variables_push_config', 'variables_pull_config', 'variables_build_config', 'logfire_info', 'get_baggage', 'set_baggage', 'get_context', 'attach_context', 'url_from_eval']
__all__ = ['Logfire', 'LogfireSpan', 'LevelName', 'AdvancedOptions', 'ConsoleOptions', 'CodeSource', 'PydanticPlugin', 'configure', 'span', 'instrument', 'log', 'trace', 'debug', 'notice', 'info', 'warn', 'warning', 'error', 'exception', 'fatal', 'force_flush', 'log_slow_async_callbacks', 'install_auto_tracing', 'instrument_asgi', 'instrument_wsgi', 'instrument_pydantic', 'instrument_pydantic_ai', 'instrument_fastapi', 'instrument_openai', 'instrument_openai_agents', 'instrument_anthropic', 'instrument_google_genai', 'instrument_litellm', 'instrument_dspy', 'instrument_print', 'instrument_asyncpg', 'instrument_httpx', 'instrument_celery', 'instrument_requests', 'instrument_psycopg', 'instrument_django', 'instrument_flask', 'instrument_starlette', 'instrument_aiohttp_client', 'instrument_aiohttp_server', 'instrument_sqlalchemy', 'instrument_sqlite3', 'instrument_aws_lambda', 'instrument_redis', 'instrument_pymongo', 'instrument_mysql', 'instrument_surrealdb', 'instrument_system_metrics', 'instrument_mcp', 'instrument_claude_agent_sdk', 'AutoTraceModule', 'with_tags', 'with_settings', 'suppress_scopes', 'shutdown', 'no_auto_trace', 'ScrubMatch', 'ScrubbingOptions', 'VERSION', 'add_non_user_code_prefix', 'suppress_instrumentation', 'StructlogProcessor', 'LogfireLoggingHandler', 'loguru_handler', 'SamplingOptions', 'MetricsOptions', 'VariablesOptions', 'LocalVariablesOptions', 'variables', 'var', 'variables_clear', 'variables_get', 'variables_push', 'variables_push_types', 'variables_validate', 'variables_push_config', 'variables_pull_config', 'variables_build_config', 'logfire_info', 'get_baggage', 'set_baggage', 'get_context', 'attach_context', 'url_from_eval', 'Artifact', 'UploadMode']

DEFAULT_LOGFIRE_INSTANCE = Logfire()
span = DEFAULT_LOGFIRE_INSTANCE.span
Expand Down
111 changes: 111 additions & 0 deletions logfire-api/logfire_api/_internal/artifacts.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import os
from abc import ABC, abstractmethod
from typing import IO, Any

from _typeshed import Incomplete

__all__ = ['Artifact', 'UploadMode']

UploadMode: Incomplete

class ArtifactSource(ABC):
"""A source of artifact bytes — normalises bytes, files, and handles to one surface.

Deliberately small so that streaming sources can be added later as a new subclass
without touching `Artifact`, the upload handshake, or the backend.
"""
@abstractmethod
def digest(self) -> tuple[str, int]:
"""Return `(sha256_hex, size_bytes)` for the content."""
@abstractmethod
def read(self) -> bytes:
"""Return the full content as bytes, for upload."""

class _BytesSource(ArtifactSource):
"""An in-memory blob."""
def __init__(self, data: bytes) -> None: ...
def digest(self) -> tuple[str, int]: ...
def read(self) -> bytes: ...

class _PathSource(ArtifactSource):
"""A file on disk.

Hashed and uploaded by reading the path, so a `background` upload of a file path
holds no bytes in memory.
"""
def __init__(self, path: str | os.PathLike[str]) -> None: ...
def digest(self) -> tuple[str, int]: ...
def read(self) -> bytes: ...

class Artifact:
"""A binary blob to attach to a span — an image, audio clip, PDF, large JSON, etc.

Pass an `Artifact` as a span or log attribute value. Logfire uploads the blob to
object storage out of band and embeds a small reference in the span.

Examples:
```python
import logfire

logfire.configure()

# From a file path.
logfire.info('chart generated', chart=logfire.Artifact.from_file('chart.png'))

# From in-memory bytes.
logfire.info('thumbnail', image=logfire.Artifact(png_bytes, content_type='image/png'))

# From an open binary handle (including temporary / spooled files).
with open('report.pdf', 'rb') as handle:
logfire.info('report', report=logfire.Artifact.from_file_handle(handle))
```
"""
def __init__(self, data: bytes, *, filename: str | None = None, content_type: str | None = None, upload: UploadMode = 'background') -> None:
"""Create an artifact from in-memory bytes.

Args:
data: The blob bytes.
filename: Optional original filename, shown in the UI and used to guess the
content type.
content_type: MIME type of the blob. Guessed from `filename` when omitted,
falling back to `application/octet-stream`.
upload: When to upload the blob — see [`UploadMode`][logfire.UploadMode].
Defaults to `background`.
"""
@classmethod
def from_file(cls, path: str | os.PathLike[str], *, filename: str | None = None, content_type: str | None = None, upload: UploadMode = 'background') -> Artifact:
"""Create an artifact from a file path.

The file is read once to hash it and again to upload it, so a `background`
upload of a file path holds no bytes in memory.

Args:
path: Path to the file.
filename: Original filename to record. Defaults to the basename of `path`.
content_type: MIME type. Guessed from the path/filename when omitted.
upload: When to upload the blob — see [`UploadMode`][logfire.UploadMode].
"""
@classmethod
def from_file_handle(cls, handle: IO[bytes], *, filename: str | None = None, content_type: str | None = None, upload: UploadMode = 'background') -> Artifact:
"""Create an artifact from an open binary file handle.

Works with any binary handle, including `tempfile.SpooledTemporaryFile` and
`tempfile.NamedTemporaryFile`. The handle is read in full immediately, so the
caller may close it as soon as this returns.

Args:
handle: An open binary (`'rb'`) file-like object.
filename: Original filename to record. Defaults to the handle's `name`.
content_type: MIME type. Guessed from the filename when omitted.
upload: When to upload the blob — see [`UploadMode`][logfire.UploadMode].
"""
@property
def sha256(self) -> str:
"""The hex sha256 of the blob — its content-addressed identity."""
@property
def size_bytes(self) -> int:
"""The size of the blob in bytes."""
def read(self) -> bytes:
"""Read the full blob into memory (used by the uploader)."""
def reference(self) -> dict[str, Any]:
"""The reference object embedded into the span attribute in place of the blob."""
Loading
Loading