diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6dc679f4..50d978cb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -131,10 +131,30 @@ jobs: env_vars: PYTHON - run: uv run coverage report --fail-under 100 + test-pyodide: + name: test on Pyodide + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "0.4.30" + enable-cache: true + + - uses: actions/setup-node@v4 + with: + node-version: "23" + + - run: make test-pyodide + env: + UV_PYTHON: ${{ matrix.python-version }} + # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() - needs: [lint, docs, test, coverage] + needs: [lint, docs, test, coverage, test-pyodide] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed diff --git a/Makefile b/Makefile index b2d1dd6b..7065c381 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,11 @@ testcov: test @echo "building coverage html" uv run coverage html --show-contexts +.PHONY: test-pyodide # Check logfire runs with pyodide +test-pyodide: + uv build + cd pyodide_test && npm install && npm test + .PHONY: docs # Build the documentation docs: uv run mkdocs build diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 6121e57b..eca34da0 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -5,6 +5,7 @@ import functools import json import os +import platform import re import sys import time @@ -708,6 +709,8 @@ def _initialize(self) -> None: if self._initialized: # pragma: no cover return + emscripten = platform.system().lower() == 'emscripten' + with suppress_instrumentation(): otel_resource_attributes: dict[str, Any] = { ResourceAttributes.SERVICE_NAME: self.service_name, @@ -744,7 +747,11 @@ def _initialize(self) -> None: ): otel_resource_attributes[RESOURCE_ATTRIBUTES_CODE_WORK_DIR] = os.getcwd() - resource = Resource.create(otel_resource_attributes) + if emscripten: # pragma: no cover + # Resource.create creates a thread pool which fails in Pyodide / Emscripten + resource = Resource(otel_resource_attributes) + else: + resource = Resource.create(otel_resource_attributes) # Set service instance ID to a random UUID if it hasn't been set already. # Setting it above would have also mostly worked and allowed overriding via OTEL_RESOURCE_ATTRIBUTES, @@ -849,8 +856,11 @@ def check_token(): if show_project_link and validated_credentials is not None: validated_credentials.print_token_summary() - thread = Thread(target=check_token, name='check_logfire_token') - thread.start() + if emscripten: # pragma: no cover + check_token() + else: + thread = Thread(target=check_token, name='check_logfire_token') + thread.start() headers = {'User-Agent': f'logfire/{VERSION}', 'Authorization': self.token} session = OTLPExporterHttpSession(max_body_size=OTLP_MAX_BODY_SIZE) @@ -864,10 +874,19 @@ def check_token(): span_exporter = RetryFewerSpansSpanExporter(span_exporter) span_exporter = RemovePendingSpansExporter(span_exporter) schedule_delay_millis = _get_int_from_env(OTEL_BSP_SCHEDULE_DELAY) or 500 - add_span_processor(BatchSpanProcessor(span_exporter, schedule_delay_millis=schedule_delay_millis)) + if emscripten: # pragma: no cover + # BatchSpanProcessor uses threads which fail in Pyodide / Emscripten + logfire_processor = SimpleSpanProcessor(span_exporter) + else: + logfire_processor = BatchSpanProcessor( + span_exporter, schedule_delay_millis=schedule_delay_millis + ) + add_span_processor(logfire_processor) - if metric_readers is not None: - metric_readers += [ + # TODO should we warn here if we have metrics but we're in emscripten? + # I guess we could do some hack to use InMemoryMetricReader and call it after user code has run? + if metric_readers is not None and not emscripten: + metric_readers.append( PeriodicExportingMetricReader( QuietMetricExporter( OTLPMetricExporter( @@ -883,7 +902,7 @@ def check_token(): preferred_temporality=METRICS_PREFERRED_TEMPORALITY, ) ) - ] + ) if processors_with_pending_spans: pending_multiprocessor = SynchronousMultiSpanProcessor() diff --git a/pyodide_test/package-lock.json b/pyodide_test/package-lock.json new file mode 100644 index 00000000..1dd16221 --- /dev/null +++ b/pyodide_test/package-lock.json @@ -0,0 +1,49 @@ +{ + "name": "pyodide_test", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pyodide_test", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "pyodide": "^0.27.2" + } + }, + "node_modules/pyodide": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.2.tgz", + "integrity": "sha512-sfA2kiUuQVRpWI4BYnU3sX5PaTTt/xrcVEmRzRcId8DzZXGGtPgCBC0gCqjUTUYSa8ofPaSjXmzESc86yvvCHg==", + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/pyodide_test/package.json b/pyodide_test/package.json new file mode 100644 index 00000000..f6e8bd39 --- /dev/null +++ b/pyodide_test/package.json @@ -0,0 +1,14 @@ +{ + "name": "pyodide_test", + "version": "0.0.0", + "main": "test.js", + "scripts": { + "test": "node --experimental-wasm-stack-switching test.mjs" + }, + "author": "", + "license": "MIT", + "description": "", + "dependencies": { + "pyodide": "^0.27.2" + } +} diff --git a/pyodide_test/test.mjs b/pyodide_test/test.mjs new file mode 100644 index 00000000..d936f0f5 --- /dev/null +++ b/pyodide_test/test.mjs @@ -0,0 +1,57 @@ +import {opendir} from 'node:fs/promises' +import path from 'path' +import assert from 'assert' +import { loadPyodide } from 'pyodide' + + +async function runTest() { + const wheelPath = await findWheel(path.join(path.resolve(import.meta.dirname, '..'), 'dist')); + const stdout = [] + const stderr = [] + const pyodide = await loadPyodide({ + + stdout: (msg) => { + stdout.push(msg) + }, + stderr: (msg) => { + stderr.push(msg) + } + }) + await pyodide.loadPackage(['micropip', 'pygments']) + console.log('Running Pyodide test...\n') + await pyodide.runPythonAsync(` +import sys +import micropip + +await micropip.install(['file:${wheelPath}']) +import logfire +logfire.configure(token='unknown', inspect_arguments=False) +logfire.info('hello {name}', name='world') +sys.stdout.flush() +sys.stderr.flush() +`) + let out = stdout.join('') + let err = stderr.join('') + console.log('stdout:', out) + console.log('stderr:', err) + assert.ok(out.includes('hello world')) + + assert.ok( + err.includes( + 'UserWarning: Logfire API returned status code 401.' + ), + ) + console.log('\n\nLogfire Pyodide tests passed 🎉') +} + + +async function findWheel(dist_dir) { + const dir = await opendir(dist_dir); + for await (const dirent of dir) { + if (dirent.name.endsWith('.whl')) { + return path.join(dist_dir, dirent.name); + } + } +} + +runTest().catch(console.error) diff --git a/pyproject.toml b/pyproject.toml index 83733e09..86dde5ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -248,17 +248,7 @@ quote-style = "single" typeCheckingMode = "strict" reportUnnecessaryTypeIgnoreComment = true reportMissingTypeStubs = false -exclude = [ - "docs/**/*.py", - "examples/**/*.py", - "site/**/*.py", - ".venv", - "venv*", - "**/venv*", - "ignoreme", - "out", - "logfire-api", -] +include = ["logfire", "tests"] venvPath = "." venv = ".venv"