mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 23:07:14 +00:00
feat(telemetry): add isolated beta quality diagnostics and release identity
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""Execute real Core boundaries with content canaries and early failures."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
|
||||
from langbot.pkg.agent.runner.reply_stream import ReplyStreamSession, ReplyStreamRequest
|
||||
|
||||
|
||||
def make_ap():
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={'space': {'url': 'https://example.invalid'}}))
|
||||
ap.persistence_mgr = SimpleNamespace(get_db_engine=lambda: None)
|
||||
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0b2', instance_id='instance-test')
|
||||
return ap
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('processor', ['pipeline', 'agent', 'event_processor'])
|
||||
async def test_real_orchestrator_prepare_failure(processor):
|
||||
ap = make_ap()
|
||||
registry = SimpleNamespace(get=AsyncMock(side_effect=ValueError('CANARY private runner URL')))
|
||||
orchestrator = AgentRunOrchestrator(ap, registry)
|
||||
context = ExecutionContext(instance_uuid='instance-test', workspace_uuid=str(uuid4()), placement_generation=1)
|
||||
event = SimpleNamespace(workspace_id=context.workspace_uuid, event_type='message.received')
|
||||
binding = SimpleNamespace(runner_id='CANARY', processor_type=processor)
|
||||
with pytest.raises(ValueError, match='CANARY'):
|
||||
await anext(orchestrator.run(event, binding, adapter_context={'_execution_context': context}))
|
||||
records = ap.diagnostics.pending
|
||||
assert [r['outcome'] for r in records] == ['started', 'failed']
|
||||
assert records[-1]['stage'] == 'prepare'
|
||||
assert records[-1]['workspace_uuid'] == context.workspace_uuid
|
||||
assert records[-1]['processor_type'] == processor
|
||||
assert 'CANARY' not in json.dumps(records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_reply_stream_mock_is_not_platform_success():
|
||||
ap = make_ap()
|
||||
event = SimpleNamespace(
|
||||
delivery=SimpleNamespace(reply_target={}, surface='webui', platform_capabilities={'debug_mock': True})
|
||||
)
|
||||
session = ReplyStreamSession(event)
|
||||
# Real runtime supplies the manager at construction from the orchestrator.
|
||||
session.diagnostics = ap.diagnostics
|
||||
result = await session.apply(ReplyStreamRequest(stream_id=uuid4(), operation='finish', text='CANARY user reply'))
|
||||
assert result['mock'] is True and result['text'] == 'CANARY user reply'
|
||||
assert ap.diagnostics.pending[-1]['source'] == 'webui_debug'
|
||||
assert ap.diagnostics.pending[-1]['attributes']['synthetic'] is True
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_bot_route_projects_status_not_reason():
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
ap = make_ap()
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.ap = ap
|
||||
bot.logger = SimpleNamespace(info=AsyncMock())
|
||||
await bot._record_event_route_trace(
|
||||
event_type='message.received',
|
||||
status='not_matched',
|
||||
text='CANARY secret',
|
||||
reason='CANARY',
|
||||
failure_code='route_not_found',
|
||||
)
|
||||
records = ap.diagnostics.pending
|
||||
assert records[-1]['outcome'] == 'skipped'
|
||||
assert records[-1]['reason_code'] == 'route_not_found'
|
||||
assert 'CANARY' not in json.dumps(records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_conversion_failure_before_bot_manager(monkeypatch):
|
||||
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter
|
||||
|
||||
ap = make_ap()
|
||||
context = ExecutionContext(instance_uuid='instance-test', workspace_uuid=str(uuid4()), placement_generation=1)
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
logger = EventLogger('test', ap, context, 'test')
|
||||
logger.error = AsyncMock()
|
||||
logger.warning = AsyncMock()
|
||||
adapter = TelegramAdapter({'token': '123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ_123456789'}, logger)
|
||||
adapter.listeners = {}
|
||||
monkeypatch.setattr(
|
||||
TelegramEventConverter, '_convert_message', AsyncMock(side_effect=ValueError('CANARY conversion'))
|
||||
)
|
||||
update = SimpleNamespace(
|
||||
message=SimpleNamespace(from_user=SimpleNamespace(is_bot=False), text='CANARY text'),
|
||||
edited_message=None,
|
||||
chat_member=None,
|
||||
my_chat_member=None,
|
||||
callback_query=None,
|
||||
message_reaction=None,
|
||||
)
|
||||
callback = adapter.application.handlers[0][0].callback
|
||||
await callback(update, None)
|
||||
records = [e for e in ap.diagnostics.pending if e['stage'] == 'convert' and e['outcome'] == 'failed']
|
||||
assert records and records[-1]['adapter'] == 'telegram-omni'
|
||||
assert records[-1]['workspace_uuid'] == context.workspace_uuid
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_interaction_ack_skip_and_failure():
|
||||
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
|
||||
|
||||
ap = make_ap()
|
||||
interactions = InteractionManager(ap, store=SimpleNamespace(record_delivery_success=AsyncMock()))
|
||||
await interactions.acknowledge_submission({}, SimpleNamespace(get_supported_apis=lambda: []))
|
||||
assert ap.diagnostics.pending[-1]['outcome'] == 'skipped'
|
||||
ap.diagnostics.pending.clear()
|
||||
ap.logger = SimpleNamespace(warning=lambda *a: None)
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=lambda: ['interaction.acknowledge'],
|
||||
call_platform_api=AsyncMock(side_effect=ValueError('CANARY ack')),
|
||||
)
|
||||
await interactions.acknowledge_submission({'delivery_result': {'secret': 'CANARY'}}, adapter)
|
||||
assert ap.diagnostics.pending[-1]['outcome'] == 'failed'
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
def test_actual_adapter_capability_snapshot():
|
||||
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||
from langbot.pkg.telemetry.diagnostic_catalog import snapshot_bot
|
||||
|
||||
ap = make_ap()
|
||||
context = ExecutionContext(instance_uuid='instance-test', workspace_uuid=str(uuid4()), placement_generation=1)
|
||||
adapter = TelegramAdapter.model_construct(config={}, listeners={})
|
||||
snapshot_bot(SimpleNamespace(ap=ap, adapter=adapter, execution_context=context))
|
||||
records = ap.diagnostics.pending
|
||||
assert records and all(e['kind'] == 'capability' for e in records)
|
||||
api_rows = [e for e in records if e['attributes']['capability_type'] == 'api']
|
||||
assert any(e['operation'] == 'send_message' and e['attributes']['supported'] for e in api_rows)
|
||||
assert all(e['workspace_uuid'] == context.workspace_uuid for e in records)
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Independent-review regressions through real ownership and lifecycle boundaries."""
|
||||
|
||||
import asyncio
|
||||
import importlib.metadata
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.provider.modelmgr.requester import RuntimeProvider
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
|
||||
|
||||
def make_ap(identity='instance-test', disabled=False):
|
||||
ap = NS(instance_config=NS(data={'space': {'url': 'https://example.invalid', 'disable_telemetry': disabled}}))
|
||||
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0b2', instance_id=identity)
|
||||
return ap
|
||||
|
||||
|
||||
def context(identity='instance-test'):
|
||||
return ExecutionContext(instance_uuid=identity, workspace_uuid=str(uuid4()), placement_generation=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [True, False])
|
||||
async def test_real_runtime_provider_cannot_use_other_app_manager(disabled):
|
||||
a, b = make_ap('instance-A'), make_ap('instance-B', disabled)
|
||||
ca, cb = context('instance-A'), context('instance-B')
|
||||
requester = NS(ap=b, invoke_llm=AsyncMock(return_value='business result'))
|
||||
provider = RuntimeProvider(cb, NS(workspace_uuid=cb.workspace_uuid), None, requester)
|
||||
model = NS(execution_context=cb, provider=provider)
|
||||
parent = d.Span(a.diagnostics, 'api', 'review.parent', {'workspace_uuid': ca.workspace_uuid})
|
||||
with parent.activate():
|
||||
assert await provider.invoke_llm(None, model, [], execution_context=cb) == 'business result'
|
||||
assert not [e for e in a.diagnostics.pending if e['operation'] == 'model.invoke_llm']
|
||||
assert len(b.diagnostics.pending) == (0 if disabled else 2)
|
||||
for event in b.diagnostics.pending:
|
||||
assert event['instance_id'] == 'instance-B'
|
||||
assert event['workspace_uuid'] == cb.workspace_uuid
|
||||
assert event['trace_id'] != parent.fields['trace_id']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('shape', ['app', 'ap', 'logger', 'requester', 'adapter', 'direct', 'absent'])
|
||||
async def test_explicit_disabled_owner_blocks_parent_even_in_ownerless_children(shape):
|
||||
a, b = make_ap('instance-A'), make_ap('instance-B', True)
|
||||
shapes = {
|
||||
'app': b,
|
||||
'ap': NS(ap=b),
|
||||
'logger': NS(logger=NS(ap=b)),
|
||||
'requester': NS(requester=NS(ap=b)),
|
||||
'adapter': NS(adapter=NS(logger=NS(ap=b))),
|
||||
'direct': NS(diagnostics=b.diagnostics),
|
||||
'absent': NS(ap=None),
|
||||
}
|
||||
|
||||
@d.observe('api', 'review.child')
|
||||
async def child(data):
|
||||
d.event(data, 'api', 'review.point', 'succeeded')
|
||||
return 42
|
||||
|
||||
@d.observe('api', 'review.owner')
|
||||
async def call(owner):
|
||||
return await child({})
|
||||
|
||||
@d.observe('run', 'review.stream')
|
||||
async def stream(owner):
|
||||
yield await child({})
|
||||
|
||||
parent = d.Span(a.diagnostics, 'api', 'review.parent', {})
|
||||
with parent.activate():
|
||||
assert await call(shapes[shape]) == 42
|
||||
assert [x async for x in stream(shapes[shape])] == [42]
|
||||
d.event(shapes[shape], 'api', 'review.point', 'succeeded')
|
||||
assert len(a.diagnostics.pending) == 1
|
||||
assert not b.diagnostics.pending
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_cannot_select_other_instance_or_owned_workspace():
|
||||
ap = make_ap()
|
||||
ca, cb = context(), context('instance-other')
|
||||
|
||||
@d.observe('api', 'review.context')
|
||||
async def call(owner, execution_context):
|
||||
return 42
|
||||
|
||||
owner = NS(ap=ap, execution_context=ca)
|
||||
with d.Span(ap.diagnostics, 'api', 'review.parent', {'workspace_uuid': ca.workspace_uuid}).activate():
|
||||
assert await call(owner, cb) == 42
|
||||
assert await call(owner, context()) == 42
|
||||
assert not [e for e in ap.diagnostics.pending if e['operation'] == 'review.context']
|
||||
# A misplaced manager attachment is not an authoritative manager for B.
|
||||
other = make_ap('instance-other')
|
||||
other.diagnostics = ap.diagnostics
|
||||
assert await call(NS(ap=other), cb) == 42
|
||||
assert not [e for e in ap.diagnostics.pending if e['operation'] == 'review.context']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
@pytest.mark.parametrize('action', ['aclose', 'athrow_exit', 'athrow_value', 'athrow_cancel'])
|
||||
async def test_cleanup_exception_identity_matches_native(disabled, action):
|
||||
ap = make_ap(disabled=disabled)
|
||||
cleanup_error = ValueError('CANARY cleanup')
|
||||
|
||||
async def original(owner):
|
||||
try:
|
||||
yield 1
|
||||
finally:
|
||||
raise cleanup_error
|
||||
|
||||
for fn in (original, d.observe('run', 'review.cleanup')(original)):
|
||||
gen = fn(ap)
|
||||
assert isinstance(gen, AsyncGenerator)
|
||||
assert await anext(gen) == 1
|
||||
with pytest.raises(ValueError) as caught:
|
||||
if action == 'aclose':
|
||||
await gen.aclose()
|
||||
else:
|
||||
error = {
|
||||
'athrow_exit': GeneratorExit(),
|
||||
'athrow_value': KeyError('business'),
|
||||
'athrow_cancel': asyncio.CancelledError('cancel'),
|
||||
}[action]
|
||||
await gen.athrow(error)
|
||||
assert caught.value is cleanup_error
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_athrow_generator_exit_can_yield_and_close_remains_native(disabled):
|
||||
ap = make_ap(disabled=disabled)
|
||||
|
||||
async def original(owner):
|
||||
try:
|
||||
yield 1
|
||||
except GeneratorExit:
|
||||
yield 2
|
||||
yield 3
|
||||
|
||||
for fn in (original, d.observe('run', 'review.exit')(original)):
|
||||
gen = fn(ap)
|
||||
assert await anext(gen) == 1
|
||||
assert await gen.athrow(GeneratorExit()) == 2
|
||||
assert await anext(gen) == 3
|
||||
await gen.aclose()
|
||||
gen = fn(ap)
|
||||
assert await anext(gen) == 1
|
||||
with pytest.raises(RuntimeError, match='ignored GeneratorExit'):
|
||||
await gen.aclose()
|
||||
# The native generator is still suspended after the refused close.
|
||||
assert await anext(gen) == 3
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await anext(gen)
|
||||
await gen.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_generator_send_throw_cancellation_and_primary_exception(disabled):
|
||||
ap = make_ap(disabled=disabled)
|
||||
entered = asyncio.Event()
|
||||
primary = ValueError('CANARY business')
|
||||
|
||||
async def original(owner):
|
||||
value = yield 1
|
||||
try:
|
||||
yield value
|
||||
except KeyError:
|
||||
yield 3
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
for fn in (original, d.observe('run', 'review.protocol')(original)):
|
||||
gen = fn(ap)
|
||||
assert await anext(gen) == 1
|
||||
assert await gen.asend(7) == 7
|
||||
assert await gen.athrow(KeyError('throw')) == 3
|
||||
entered.clear()
|
||||
task = asyncio.create_task(anext(gen))
|
||||
await entered.wait()
|
||||
task.cancel('native cancellation')
|
||||
with pytest.raises(asyncio.CancelledError, match='native cancellation'):
|
||||
await task
|
||||
await gen.aclose()
|
||||
|
||||
async def failing(owner):
|
||||
yield 1
|
||||
raise primary
|
||||
|
||||
gen = d.observe('run', 'review.primary')(failing)(ap)
|
||||
assert await anext(gen) == 1
|
||||
with pytest.raises(ValueError) as caught:
|
||||
await anext(gen)
|
||||
assert caught.value is primary
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_kook_native_entry_conversion_failure_has_trusted_workspace():
|
||||
from langbot.pkg.platform.adapters.kook.adapter import KookAdapter
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
ap, ctx = make_ap(), context()
|
||||
logger = EventLogger('test', ap, ctx, 'test')
|
||||
logger.error = AsyncMock()
|
||||
adapter = KookAdapter({'token': 'test-placeholder'}, logger)
|
||||
# Invalid native timestamp fails inside the real static converter.
|
||||
await adapter._handle_event({'type': 255, 'msg_timestamp': 'CANARY invalid', 'workspace_uuid': str(uuid4())}, 1)
|
||||
logger.error.assert_awaited_once()
|
||||
failures = [e for e in ap.diagnostics.pending if e['outcome'] == 'failed']
|
||||
assert any(e['stage'] == 'convert' for e in failures)
|
||||
assert any(e['operation'] == 'platform.receive' for e in failures)
|
||||
assert all(e['workspace_uuid'] == ctx.workspace_uuid for e in ap.diagnostics.pending)
|
||||
assert all(e['adapter'] == 'kook-omni' for e in ap.diagnostics.pending)
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boot_stages(monkeypatch):
|
||||
from langbot.pkg.core import boot
|
||||
|
||||
stages_run = []
|
||||
|
||||
class Stage:
|
||||
async def run(self, app):
|
||||
stages_run.append(app)
|
||||
|
||||
# Earlier registry tests clear this shared dictionary; cached imports do not
|
||||
# re-register stages. Own the registry per test and restore it on teardown.
|
||||
monkeypatch.setattr(boot.stage, 'preregistered_stages', {'LoadConfigStage': Stage, 'GenKeysStage': Stage})
|
||||
return stages_run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('failure', ['constructor', 'metadata', 'session', 'start'])
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_real_make_app_optional_initialization_fail_open(monkeypatch, boot_stages, failure, disabled):
|
||||
from langbot.pkg.core import boot
|
||||
|
||||
ap = NS(
|
||||
instance_config=NS(data={'space': {'url': 'https://example.invalid', 'disable_telemetry': disabled}}),
|
||||
initialize=AsyncMock(),
|
||||
shutdown=AsyncMock(),
|
||||
)
|
||||
stages_run = boot_stages
|
||||
monkeypatch.setattr(boot.app, 'Application', lambda: ap)
|
||||
monkeypatch.setattr(boot, 'stage_order', ['LoadConfigStage', 'GenKeysStage'])
|
||||
error = RuntimeError('optional diagnostics')
|
||||
if failure == 'constructor':
|
||||
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', Mock(side_effect=error))
|
||||
elif failure == 'metadata':
|
||||
monkeypatch.setattr(
|
||||
importlib.metadata, 'version', Mock(side_effect=importlib.metadata.PackageNotFoundError('langbot'))
|
||||
)
|
||||
else:
|
||||
manager = NS(
|
||||
start_session=AsyncMock(), start=Mock(), shutdown=AsyncMock(side_effect=RuntimeError('optional cleanup'))
|
||||
)
|
||||
getattr(manager, 'start_session' if failure == 'session' else 'start').side_effect = error
|
||||
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', Mock(return_value=manager))
|
||||
assert await boot.make_app(asyncio.get_running_loop()) is ap
|
||||
assert len(stages_run) == 2
|
||||
ap.initialize.assert_awaited_once()
|
||||
ap.shutdown.assert_not_awaited()
|
||||
assert getattr(ap, 'diagnostics', None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_make_app_session_cancellation_and_shutdown_failure_preserve_primary(monkeypatch, boot_stages):
|
||||
from langbot.pkg.core import boot
|
||||
|
||||
cancelled = asyncio.CancelledError('genuine cancellation')
|
||||
ap = NS(
|
||||
instance_config=NS(data={'space': {'url': 'https://example.invalid'}}),
|
||||
initialize=AsyncMock(),
|
||||
shutdown=AsyncMock(side_effect=RuntimeError('shutdown error')),
|
||||
)
|
||||
manager = NS(start_session=AsyncMock(side_effect=cancelled), start=Mock(), shutdown=AsyncMock())
|
||||
monkeypatch.setattr(boot.app, 'Application', lambda: ap)
|
||||
monkeypatch.setattr(boot, 'stage_order', ['GenKeysStage'])
|
||||
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', Mock(return_value=manager))
|
||||
with pytest.raises(asyncio.CancelledError) as caught:
|
||||
await boot.make_app(asyncio.get_running_loop())
|
||||
assert caught.value is cancelled
|
||||
ap.initialize.assert_not_awaited()
|
||||
ap.shutdown.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_make_app_business_failure_not_masked_by_optional_shutdown(monkeypatch, boot_stages):
|
||||
from langbot.pkg.core import boot
|
||||
|
||||
primary = ValueError('business startup')
|
||||
ap = boot.app.Application()
|
||||
ap.instance_config = NS(data={'space': {'url': 'https://example.invalid'}})
|
||||
ap.initialize = AsyncMock(side_effect=primary)
|
||||
manager = make_ap().diagnostics
|
||||
manager.start_session = AsyncMock()
|
||||
manager.start = Mock()
|
||||
manager.shutdown = AsyncMock(side_effect=RuntimeError('optional shutdown'))
|
||||
monkeypatch.setattr(boot.app, 'Application', lambda: ap)
|
||||
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', lambda *a, **kw: manager)
|
||||
monkeypatch.setattr(boot, 'stage_order', ['GenKeysStage'])
|
||||
|
||||
with pytest.raises(ValueError) as caught:
|
||||
await boot.make_app(asyncio.get_running_loop())
|
||||
assert caught.value is primary
|
||||
manager.shutdown.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_protocol_rejected_calls_and_unstarted_throw_match_native(disabled):
|
||||
ap = make_ap(disabled=disabled)
|
||||
|
||||
async def original(owner):
|
||||
try:
|
||||
yield 1
|
||||
except GeneratorExit:
|
||||
yield 2
|
||||
yield 3
|
||||
|
||||
async def record(fn, actions):
|
||||
gen = fn(ap)
|
||||
results = []
|
||||
for name, values in actions:
|
||||
try:
|
||||
results.append(('value', await getattr(gen, name)(*values)))
|
||||
except BaseException as exc:
|
||||
results.append((type(exc).__name__, str(exc)))
|
||||
# Fully exhaust any generator left suspended by a refused close.
|
||||
try:
|
||||
while True:
|
||||
await anext(gen)
|
||||
except (StopAsyncIteration, GeneratorExit):
|
||||
pass
|
||||
return results
|
||||
|
||||
wrapped = d.observe('run', 'review.protocol_matrix')(original)
|
||||
for actions in (
|
||||
[('aclose', ())],
|
||||
[('athrow', (GeneratorExit(),))],
|
||||
[('athrow', (ValueError('unstarted'),))],
|
||||
[('asend', (7,)), ('__anext__', ()), ('athrow', (ValueError('primary'),))],
|
||||
[('__anext__', ()), ('aclose', ()), ('__anext__', ()), ('aclose', ())],
|
||||
[('__anext__', ()), ('athrow', (GeneratorExit(),)), ('__anext__', ()), ('aclose', ())],
|
||||
):
|
||||
assert await record(wrapped, actions) == await record(original, actions)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_native_task_cancel_cleanup_error_wins_without_extra_close(disabled):
|
||||
ap = make_ap(disabled=disabled)
|
||||
error = ValueError('native cleanup')
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def original(owner):
|
||||
yield 1
|
||||
try:
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
raise error
|
||||
|
||||
for fn in (original, d.observe('run', 'review.cancel_cleanup')(original)):
|
||||
gen = fn(ap)
|
||||
await anext(gen)
|
||||
entered.clear()
|
||||
task = asyncio.create_task(anext(gen))
|
||||
await entered.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(ValueError) as caught:
|
||||
await task
|
||||
assert caught.value is error
|
||||
await gen.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_b2_handler_consumes_observed_async_generator(tmp_path):
|
||||
from langbot_plugin.runtime.io.handler import Handler, ActionResponse
|
||||
from langbot_plugin.entities.io.resp import ChunkStatus
|
||||
|
||||
ap = make_ap()
|
||||
handler = Handler(NS(), file_storage_dir=str(tmp_path))
|
||||
handler._send_message = AsyncMock()
|
||||
|
||||
@d.observe('api', 'host.review_stream', ap=ap)
|
||||
async def stream(data):
|
||||
yield ActionResponse.success({'business': 'unchanged'})
|
||||
|
||||
handler.actions['review_stream'] = stream
|
||||
await handler._handle_action({'seq_id': 1, 'action': 'review_stream', 'data': {}})
|
||||
responses = [call.args[0] for call in handler._send_message.await_args_list]
|
||||
assert len(responses) == 2
|
||||
assert responses[0].data == {'business': 'unchanged'}
|
||||
assert [response.chunk_status for response in responses] == [ChunkStatus.CONTINUE, ChunkStatus.END]
|
||||
assert [e['outcome'] for e in ap.diagnostics.pending] == ['started', 'succeeded']
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Content-free diagnostics contract and lifecycle regression tests."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
|
||||
|
||||
def manager(version='4.11.0-beta.2', **config):
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={'space': {'url': 'https://example.invalid', **config}}))
|
||||
ap.diagnostics = d.DiagnosticsManager(ap, version=version, instance_id='instance-test', capacity=4)
|
||||
return ap.diagnostics
|
||||
|
||||
|
||||
def test_release_gate_and_privacy():
|
||||
assert not manager('4.11.0').enabled
|
||||
assert manager().enabled
|
||||
assert manager('4.11.0b2').enabled
|
||||
assert not manager(disable_telemetry=True).enabled
|
||||
assert not manager(disable_beta_diagnostics=True).enabled
|
||||
m = manager()
|
||||
m.emit(
|
||||
'api',
|
||||
'test.operation',
|
||||
'failed',
|
||||
attributes={'prompt': 'CANARY', 'plugin_id': 'CANARY', 'attempts': 1},
|
||||
error=ValueError('CANARY https://secret/token'),
|
||||
workspace_uuid=str(uuid4()),
|
||||
)
|
||||
payload = m.pending[0]
|
||||
assert 'CANARY' not in json.dumps(payload)
|
||||
assert payload['attributes'] == {'attempts': 1}
|
||||
assert payload['error_type'] == 'ValueError'
|
||||
assert payload['instance_id'] != payload['workspace_uuid']
|
||||
assert payload['sample_rate'] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_boundary_skips_all_projection(monkeypatch):
|
||||
m = manager('4.11.0')
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise AssertionError('diagnostic machinery ran while disabled')
|
||||
|
||||
monkeypatch.setattr(d, 'Span', broken)
|
||||
monkeypatch.setattr(d, 'result_outcome', broken)
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('api', 'test.disabled', fields=broken)
|
||||
async def call(self):
|
||||
return 42
|
||||
|
||||
@d.observe('run', 'test.disabled', fields=broken)
|
||||
async def stream(self):
|
||||
yield 42
|
||||
|
||||
assert await Service().call() == 42
|
||||
assert [v async for v in Service().stream()] == [42]
|
||||
assert not m.pending
|
||||
|
||||
|
||||
def test_bounds_and_disable_clear():
|
||||
m = manager()
|
||||
for _ in range(8):
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
assert len(m.pending) == 4
|
||||
assert m.counters['dropped'] == 4
|
||||
m.ap.instance_config.data['space']['disable_telemetry'] = True
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
assert not m.pending
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_ack_retries_same_identity_and_drops_rejected():
|
||||
m = manager()
|
||||
for _ in range(3):
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
ids = [e['event_id'] for e in m.pending]
|
||||
requests = []
|
||||
|
||||
async def handler(req):
|
||||
requests.append(json.loads(req.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
'code': 200,
|
||||
'data': {'accepted_event_ids': [ids[0]], 'rejected': [{'event_id': ids[1], 'code': 'invalid_event'}]},
|
||||
},
|
||||
)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
await m.flush_once()
|
||||
assert [e['event_id'] for e in m.pending] == [ids[2]]
|
||||
assert m.counters['acked'] == 1
|
||||
assert m.counters['dropped'] == 1
|
||||
assert requests[0]['schema_version'] == 1
|
||||
await m.shutdown(drain_timeout=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outage_finite_retry_and_slow_credentials():
|
||||
m = manager()
|
||||
m.max_attempts = 2
|
||||
m.emit('run', 'test.operation', 'started')
|
||||
calls = []
|
||||
|
||||
async def handler(req):
|
||||
calls.append(req)
|
||||
return httpx.Response(503)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
await m.flush_once()
|
||||
await m.flush_once()
|
||||
assert not m.pending
|
||||
assert m.counters['dropped'] == 1
|
||||
assert len(calls) == 2
|
||||
|
||||
async def credentials(workspace):
|
||||
await asyncio.sleep(60)
|
||||
|
||||
m.credentials = credentials
|
||||
m.request_timeout = 0.01
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
await asyncio.wait_for(m.flush_once(), 0.2)
|
||||
await m.shutdown(drain_timeout=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inflight_disable_cancels_and_clears():
|
||||
m = manager()
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def handler(req):
|
||||
entered.set()
|
||||
await asyncio.sleep(60)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
m.start()
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
await asyncio.wait_for(entered.wait(), 1)
|
||||
m.ap.instance_config.data['space']['disable_beta_diagnostics'] = True
|
||||
await asyncio.sleep(0.3)
|
||||
assert not m.pending
|
||||
await asyncio.wait_for(m.shutdown(drain_timeout=0), 0.5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observe_returns_errors_and_cancellation():
|
||||
m = manager()
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('api', 'test.operation')
|
||||
async def call(self, error=None):
|
||||
if error:
|
||||
raise error
|
||||
return {'secret': 'CANARY'}
|
||||
|
||||
s = Service()
|
||||
assert await s.call() == {'secret': 'CANARY'}
|
||||
with pytest.raises(ValueError):
|
||||
await s.call(ValueError('CANARY'))
|
||||
m.pending.clear()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await s.call(asyncio.CancelledError())
|
||||
assert m.pending[-1]['outcome'] == 'cancelled'
|
||||
assert 'CANARY' not in json.dumps(m.pending)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generator_send_throw_close_and_context_isolation():
|
||||
m = manager()
|
||||
m.capacity = 30
|
||||
closed = []
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('run', 'test.operation')
|
||||
async def stream(self):
|
||||
try:
|
||||
value = yield 1
|
||||
try:
|
||||
yield value
|
||||
except ValueError:
|
||||
yield 3
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
gen = Service().stream()
|
||||
assert await anext(gen) == 1
|
||||
assert d.current_span() is None
|
||||
assert await gen.asend(7) == 7
|
||||
assert await gen.athrow(ValueError('CANARY')) == 3
|
||||
await gen.aclose()
|
||||
assert closed == [True]
|
||||
assert m.pending[-1]['outcome'] == 'cancelled'
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generator_early_failure_and_explicit_terminal():
|
||||
m = manager()
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('run', 'test.operation')
|
||||
async def stream(self, fail):
|
||||
if fail:
|
||||
raise ValueError('prepare CANARY')
|
||||
d.set_outcome('failed', reason_code='runner_failed')
|
||||
yield 1
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await anext(Service().stream(True))
|
||||
assert m.pending[-1]['outcome'] == 'failed'
|
||||
m.pending.clear()
|
||||
assert [v async for v in Service().stream(False)] == [1]
|
||||
assert m.pending[-1]['outcome'] == 'failed'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projection_fault_does_not_replace_business_return(monkeypatch):
|
||||
m = manager()
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise RuntimeError('CANARY projection')
|
||||
|
||||
monkeypatch.setattr(d, 'result_outcome', broken)
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('api', 'test.projection_fault')
|
||||
async def call(self):
|
||||
return 42
|
||||
|
||||
assert await Service().call() == 42
|
||||
assert m.pending[-1]['outcome'] == 'succeeded'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_batches_and_credential_failure_are_anonymous():
|
||||
m = manager()
|
||||
workspaces = [str(uuid4()), str(uuid4())]
|
||||
for workspace in workspaces:
|
||||
m.emit('api', 'test.operation', 'succeeded', workspace_uuid=workspace)
|
||||
requests = []
|
||||
|
||||
async def broken(workspace):
|
||||
raise RuntimeError('CANARY credentials')
|
||||
|
||||
m.credentials = broken
|
||||
|
||||
async def handler(request):
|
||||
data = json.loads(request.content)
|
||||
requests.append((data, dict(request.headers)))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={'code': 200, 'data': {'accepted_event_ids': [e['event_id'] for e in data['events']], 'rejected': []}},
|
||||
)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
await m.flush_once()
|
||||
await m.flush_once()
|
||||
assert len(requests) == 2
|
||||
assert [request[0]['events'][0]['workspace_uuid'] for request in requests] == workspaces
|
||||
assert all('authorization' not in headers for _, headers in requests)
|
||||
assert not m.pending
|
||||
await m.shutdown(drain_timeout=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_is_interval_delta_and_ack_replay_is_idempotent():
|
||||
m = manager()
|
||||
m.capacity = 20
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
m.report_transport()
|
||||
first = m.pending[-1]
|
||||
m.report_transport()
|
||||
second = m.pending[-1]
|
||||
assert first['attributes']['generated'] == 1
|
||||
assert second['attributes']['generated'] == 1 # Only first summary itself.
|
||||
assert first['event_id'] != second['event_id']
|
||||
ids = [e['event_id'] for e in m.pending]
|
||||
seen = []
|
||||
|
||||
async def handler(request):
|
||||
body = json.loads(request.content)
|
||||
seen.append([e['event_id'] for e in body['events']])
|
||||
return (
|
||||
httpx.Response(503)
|
||||
if len(seen) == 1
|
||||
else httpx.Response(200, json={'code': 200, 'data': {'accepted_event_ids': ids + ids, 'rejected': []}})
|
||||
)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
await m.flush_once()
|
||||
await m.flush_once()
|
||||
assert seen == [ids, ids]
|
||||
assert m.counters['acked'] == len(ids)
|
||||
assert not m.pending
|
||||
await m.shutdown(drain_timeout=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retention_and_bounded_shutdown():
|
||||
m = manager()
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
m.retention_seconds = -1
|
||||
await m.flush_once()
|
||||
assert not m.pending and m.counters['dropped'] == 1
|
||||
m.retention_seconds = 900
|
||||
|
||||
async def handler(request):
|
||||
await asyncio.sleep(60)
|
||||
return httpx.Response(503)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
await asyncio.wait_for(m.shutdown(drain_timeout=0.01), 0.5)
|
||||
assert not m.pending and m.client.is_closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_marker_restart_and_disable(tmp_path):
|
||||
marker = tmp_path / 'session.json'
|
||||
m = manager()
|
||||
m.marker_path = marker
|
||||
await m.start_session()
|
||||
assert marker.exists()
|
||||
recovered = manager()
|
||||
recovered.marker_path = marker
|
||||
await recovered.start_session()
|
||||
assert recovered.pending[-1]['attributes']['previous_session_unclean'] is True
|
||||
recovered.ap.instance_config.data['space']['disable_beta_diagnostics'] = True
|
||||
recovered.start()
|
||||
await asyncio.sleep(0.2)
|
||||
assert not marker.exists()
|
||||
await recovered.shutdown(drain_timeout=0)
|
||||
await m.shutdown(drain_timeout=0)
|
||||
Reference in New Issue
Block a user