Skip to content

Commit 2bfbddd

Browse files
authored
Support Pyodide (#818)
1 parent f4d2c63 commit 2bfbddd

File tree

7 files changed

+173
-19
lines changed

7 files changed

+173
-19
lines changed

.github/workflows/main.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,10 +131,30 @@ jobs:
131131
env_vars: PYTHON
132132
- run: uv run coverage report --fail-under 100
133133

134+
test-pyodide:
135+
name: test on Pyodide
136+
runs-on: ubuntu-latest
137+
steps:
138+
- uses: actions/checkout@v4
139+
140+
- name: Install uv
141+
uses: astral-sh/setup-uv@v3
142+
with:
143+
version: "0.4.30"
144+
enable-cache: true
145+
146+
- uses: actions/setup-node@v4
147+
with:
148+
node-version: "23"
149+
150+
- run: make test-pyodide
151+
env:
152+
UV_PYTHON: ${{ matrix.python-version }}
153+
134154
# https://github.com/marketplace/actions/alls-green#why used for branch protection checks
135155
check:
136156
if: always()
137-
needs: [lint, docs, test, coverage]
157+
needs: [lint, docs, test, coverage, test-pyodide]
138158
runs-on: ubuntu-latest
139159
steps:
140160
- name: Decide whether the needed jobs succeeded or failed

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ testcov: test
4646
@echo "building coverage html"
4747
uv run coverage html --show-contexts
4848

49+
.PHONY: test-pyodide # Check logfire runs with pyodide
50+
test-pyodide:
51+
uv build
52+
cd pyodide_test && npm install && npm test
53+
4954
.PHONY: docs # Build the documentation
5055
docs:
5156
uv run mkdocs build

logfire/_internal/config.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import functools
66
import json
77
import os
8+
import platform
89
import re
910
import sys
1011
import time
@@ -708,6 +709,8 @@ def _initialize(self) -> None:
708709
if self._initialized: # pragma: no cover
709710
return
710711

712+
emscripten = platform.system().lower() == 'emscripten'
713+
711714
with suppress_instrumentation():
712715
otel_resource_attributes: dict[str, Any] = {
713716
ResourceAttributes.SERVICE_NAME: self.service_name,
@@ -744,7 +747,11 @@ def _initialize(self) -> None:
744747
):
745748
otel_resource_attributes[RESOURCE_ATTRIBUTES_CODE_WORK_DIR] = os.getcwd()
746749

747-
resource = Resource.create(otel_resource_attributes)
750+
if emscripten: # pragma: no cover
751+
# Resource.create creates a thread pool which fails in Pyodide / Emscripten
752+
resource = Resource(otel_resource_attributes)
753+
else:
754+
resource = Resource.create(otel_resource_attributes)
748755

749756
# Set service instance ID to a random UUID if it hasn't been set already.
750757
# Setting it above would have also mostly worked and allowed overriding via OTEL_RESOURCE_ATTRIBUTES,
@@ -849,8 +856,11 @@ def check_token():
849856
if show_project_link and validated_credentials is not None:
850857
validated_credentials.print_token_summary()
851858

852-
thread = Thread(target=check_token, name='check_logfire_token')
853-
thread.start()
859+
if emscripten: # pragma: no cover
860+
check_token()
861+
else:
862+
thread = Thread(target=check_token, name='check_logfire_token')
863+
thread.start()
854864

855865
headers = {'User-Agent': f'logfire/{VERSION}', 'Authorization': self.token}
856866
session = OTLPExporterHttpSession(max_body_size=OTLP_MAX_BODY_SIZE)
@@ -864,10 +874,19 @@ def check_token():
864874
span_exporter = RetryFewerSpansSpanExporter(span_exporter)
865875
span_exporter = RemovePendingSpansExporter(span_exporter)
866876
schedule_delay_millis = _get_int_from_env(OTEL_BSP_SCHEDULE_DELAY) or 500
867-
add_span_processor(BatchSpanProcessor(span_exporter, schedule_delay_millis=schedule_delay_millis))
877+
if emscripten: # pragma: no cover
878+
# BatchSpanProcessor uses threads which fail in Pyodide / Emscripten
879+
logfire_processor = SimpleSpanProcessor(span_exporter)
880+
else:
881+
logfire_processor = BatchSpanProcessor(
882+
span_exporter, schedule_delay_millis=schedule_delay_millis
883+
)
884+
add_span_processor(logfire_processor)
868885

869-
if metric_readers is not None:
870-
metric_readers += [
886+
# TODO should we warn here if we have metrics but we're in emscripten?
887+
# I guess we could do some hack to use InMemoryMetricReader and call it after user code has run?
888+
if metric_readers is not None and not emscripten:
889+
metric_readers.append(
871890
PeriodicExportingMetricReader(
872891
QuietMetricExporter(
873892
OTLPMetricExporter(
@@ -883,7 +902,7 @@ def check_token():
883902
preferred_temporality=METRICS_PREFERRED_TEMPORALITY,
884903
)
885904
)
886-
]
905+
)
887906

888907
if processors_with_pending_spans:
889908
pending_multiprocessor = SynchronousMultiSpanProcessor()

pyodide_test/package-lock.json

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyodide_test/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "pyodide_test",
3+
"version": "0.0.0",
4+
"main": "test.js",
5+
"scripts": {
6+
"test": "node --experimental-wasm-stack-switching test.mjs"
7+
},
8+
"author": "",
9+
"license": "MIT",
10+
"description": "",
11+
"dependencies": {
12+
"pyodide": "^0.27.2"
13+
}
14+
}

pyodide_test/test.mjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {opendir} from 'node:fs/promises'
2+
import path from 'path'
3+
import assert from 'assert'
4+
import { loadPyodide } from 'pyodide'
5+
6+
7+
async function runTest() {
8+
const wheelPath = await findWheel(path.join(path.resolve(import.meta.dirname, '..'), 'dist'));
9+
const stdout = []
10+
const stderr = []
11+
const pyodide = await loadPyodide({
12+
13+
stdout: (msg) => {
14+
stdout.push(msg)
15+
},
16+
stderr: (msg) => {
17+
stderr.push(msg)
18+
}
19+
})
20+
await pyodide.loadPackage(['micropip', 'pygments'])
21+
console.log('Running Pyodide test...\n')
22+
await pyodide.runPythonAsync(`
23+
import sys
24+
import micropip
25+
26+
await micropip.install(['file:${wheelPath}'])
27+
import logfire
28+
logfire.configure(token='unknown', inspect_arguments=False)
29+
logfire.info('hello {name}', name='world')
30+
sys.stdout.flush()
31+
sys.stderr.flush()
32+
`)
33+
let out = stdout.join('')
34+
let err = stderr.join('')
35+
console.log('stdout:', out)
36+
console.log('stderr:', err)
37+
assert.ok(out.includes('hello world'))
38+
39+
assert.ok(
40+
err.includes(
41+
'UserWarning: Logfire API returned status code 401.'
42+
),
43+
)
44+
console.log('\n\nLogfire Pyodide tests passed 🎉')
45+
}
46+
47+
48+
async function findWheel(dist_dir) {
49+
const dir = await opendir(dist_dir);
50+
for await (const dirent of dir) {
51+
if (dirent.name.endsWith('.whl')) {
52+
return path.join(dist_dir, dirent.name);
53+
}
54+
}
55+
}
56+
57+
runTest().catch(console.error)

pyproject.toml

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -248,17 +248,7 @@ quote-style = "single"
248248
typeCheckingMode = "strict"
249249
reportUnnecessaryTypeIgnoreComment = true
250250
reportMissingTypeStubs = false
251-
exclude = [
252-
"docs/**/*.py",
253-
"examples/**/*.py",
254-
"site/**/*.py",
255-
".venv",
256-
"venv*",
257-
"**/venv*",
258-
"ignoreme",
259-
"out",
260-
"logfire-api",
261-
]
251+
include = ["logfire", "tests"]
262252
venvPath = "."
263253
venv = ".venv"
264254

0 commit comments

Comments
 (0)