mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat: use workspace identity for telemetry (#2371)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -22,6 +22,7 @@ from langbot.pkg.persistence.alembic_runner import (
|
||||
from langbot.pkg.utils import constants
|
||||
from langbot.pkg.utils import importutil
|
||||
from langbot.pkg.workspace.collaboration import normalize_email
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
@@ -109,6 +110,7 @@ async def test_legacy_instance_gets_stable_accounts_and_default_workspace(legacy
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test')
|
||||
assert workspace['instance_uuid'] == 'instance_migration_test'
|
||||
assert workspace['slug'] == 'default'
|
||||
assert workspace['status'] == 'active'
|
||||
@@ -149,6 +151,53 @@ async def test_workspace_upgrade_is_idempotent_and_preserves_identifiers(legacy_
|
||||
assert workspace_uuid_after == workspace_uuid_before
|
||||
|
||||
|
||||
async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||
instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f'
|
||||
old_workspace_uuid = '11111111-1111-4111-8111-111111111111'
|
||||
canonical_uuid = workspace_uuid_from_instance_id(instance_id)
|
||||
schema = sa.MetaData()
|
||||
sa.Table(
|
||||
'metadata',
|
||||
schema,
|
||||
sa.Column('key', sa.String(255), primary_key=True),
|
||||
sa.Column('value', sa.String(255)),
|
||||
)
|
||||
sa.Table(
|
||||
'workspaces',
|
||||
schema,
|
||||
sa.Column('uuid', sa.String(36), primary_key=True),
|
||||
sa.Column('instance_uuid', sa.String(255), nullable=False),
|
||||
sa.Column('slug', sa.String(255), nullable=False),
|
||||
sa.Column('source', sa.String(32), nullable=False),
|
||||
)
|
||||
sa.Table(
|
||||
'tenant_rows',
|
||||
schema,
|
||||
sa.Column('id', sa.Integer, primary_key=True),
|
||||
sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False),
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(schema.create_all)
|
||||
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
|
||||
await conn.execute(
|
||||
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
|
||||
{'uuid': old_workspace_uuid},
|
||||
)
|
||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
||||
|
||||
await run_alembic_upgrade(engine, 'head')
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
|
||||
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_workspace_kernel_upgrade_downgrade_upgrade_round_trip(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-round-trip.db"}')
|
||||
try:
|
||||
@@ -362,6 +411,47 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||
try:
|
||||
await _create_legacy_schema(engine)
|
||||
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
await run_alembic_upgrade(engine, '0016_support_admin_sessions')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
|
||||
instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'"))
|
||||
assert old_uuid
|
||||
assert instance_uuid
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
||||
),
|
||||
{'workspace_uuid': old_uuid},
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
),
|
||||
{'workspace_uuid': old_uuid},
|
||||
)
|
||||
|
||||
await run_alembic_upgrade(engine, 'head')
|
||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
||||
async with engine.connect() as conn:
|
||||
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
||||
assert await conn.scalar(
|
||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||
) == expected_uuid
|
||||
assert await conn.scalar(
|
||||
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
||||
) == expected_uuid
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
|
||||
try:
|
||||
|
||||
@@ -60,10 +60,12 @@ class TestBuildHeartbeatPayload:
|
||||
async def test_payload_shape(self):
|
||||
heartbeat = get_heartbeat_module()
|
||||
ap = make_app()
|
||||
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
||||
|
||||
assert payload['event_type'] == 'instance_heartbeat'
|
||||
assert payload['query_id'] == ''
|
||||
assert payload['workspace_uuid'] == 'workspace-a'
|
||||
assert 'instance_id' not in payload
|
||||
assert 'instance_create_ts' in payload
|
||||
assert 'timestamp' in payload
|
||||
f = payload['features']
|
||||
@@ -86,7 +88,7 @@ class TestBuildHeartbeatPayload:
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_is_json_serializable(self):
|
||||
heartbeat = get_heartbeat_module()
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app())
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a')
|
||||
json.dumps(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -94,7 +96,7 @@ class TestBuildHeartbeatPayload:
|
||||
heartbeat = get_heartbeat_module()
|
||||
ap = make_app()
|
||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=RuntimeError('db down'))
|
||||
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||
payload = await heartbeat.build_heartbeat_payload(ap, workspace_uuid='workspace-a')
|
||||
assert payload['features']['pipeline_count'] == -1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -111,8 +113,10 @@ class TestBuildHeartbeatPayload:
|
||||
('instance-a', 'workspace-a', 'pipeline-b'): object(),
|
||||
},
|
||||
)
|
||||
adapter_a = type('WorkspaceAAdapter', (), {})()
|
||||
adapter_b = type('WorkspaceBAdapter', (), {})()
|
||||
ap.platform_mgr._bots_by_key = {
|
||||
('instance-a', 'workspace-a', 'bot-a'): object(),
|
||||
('instance-a', 'workspace-a', 'bot-a'): SimpleNamespace(enable=True, adapter=adapter_a),
|
||||
}
|
||||
ap.tool_mgr = SimpleNamespace(
|
||||
mcp_tool_loader=SimpleNamespace(
|
||||
@@ -129,35 +133,46 @@ class TestBuildHeartbeatPayload:
|
||||
ap.plugin_connector._workspace_installations = {
|
||||
'workspace-a': {'plugin-a', 'plugin-b'},
|
||||
}
|
||||
ap.skill_mgr._skills_by_scope = {
|
||||
('instance-a', 'workspace-a', 1): {'skill-a': {}, 'skill-b': {}},
|
||||
('instance-a', 'workspace-b', 1): {'skill-c': {}},
|
||||
}
|
||||
ap.workspace_service.list_active_execution_bindings = AsyncMock(
|
||||
return_value=[SimpleNamespace(workspace_uuid='workspace-a')],
|
||||
return_value=[
|
||||
SimpleNamespace(workspace_uuid='workspace-a'),
|
||||
SimpleNamespace(workspace_uuid='workspace-b'),
|
||||
],
|
||||
)
|
||||
ap.platform_mgr._bots_by_key[('instance-a', 'workspace-b', 'bot-b')] = SimpleNamespace(
|
||||
enable=True, adapter=adapter_b
|
||||
)
|
||||
|
||||
payload = await heartbeat.build_heartbeat_payload(ap)
|
||||
payloads = await heartbeat.build_heartbeat_payloads(ap)
|
||||
|
||||
features = payload['features']
|
||||
assert features['pipeline_count'] == 2
|
||||
assert features['mcp_server_count'] == 3
|
||||
assert features['knowledge_base_count'] == 1
|
||||
assert features['bot_count'] == 1
|
||||
assert features['workspace_resources'] == [
|
||||
{
|
||||
'workspace_uuid': 'workspace-a',
|
||||
'bot_count': 1,
|
||||
'pipeline_count': 2,
|
||||
'knowledge_base_count': 1,
|
||||
'plugin_count': 2,
|
||||
'mcp_server_count': 3,
|
||||
'extension_count': 5,
|
||||
}
|
||||
]
|
||||
assert [payload['workspace_uuid'] for payload in payloads] == ['workspace-a', 'workspace-b']
|
||||
assert all('instance_id' not in payload for payload in payloads)
|
||||
by_workspace = {payload['workspace_uuid']: payload['features'] for payload in payloads}
|
||||
assert by_workspace['workspace-a']['pipeline_count'] == 2
|
||||
assert by_workspace['workspace-a']['mcp_server_count'] == 3
|
||||
assert by_workspace['workspace-a']['knowledge_base_count'] == 1
|
||||
assert by_workspace['workspace-a']['bot_count'] == 1
|
||||
assert by_workspace['workspace-a']['plugin_count'] == 2
|
||||
assert by_workspace['workspace-a']['extension_count'] == 5
|
||||
assert by_workspace['workspace-a']['skill_count'] == 2
|
||||
assert by_workspace['workspace-a']['adapters'] == ['WorkspaceAAdapter']
|
||||
assert by_workspace['workspace-b']['bot_count'] == 1
|
||||
assert by_workspace['workspace-b']['pipeline_count'] == 0
|
||||
assert by_workspace['workspace-b']['skill_count'] == 1
|
||||
assert by_workspace['workspace-b']['adapters'] == ['WorkspaceBAdapter']
|
||||
assert 'workspace_resources' not in by_workspace['workspace-a']
|
||||
ap.persistence_mgr.execute_async.assert_not_awaited()
|
||||
ap.workspace_service.list_active_execution_bindings.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_content_fields(self):
|
||||
"""The heartbeat must never carry message content / credentials keys."""
|
||||
heartbeat = get_heartbeat_module()
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app())
|
||||
payload = await heartbeat.build_heartbeat_payload(make_app(), workspace_uuid='workspace-a')
|
||||
flat = json.dumps(payload).lower()
|
||||
for forbidden in ('api_key', 'password', 'token', 'message_content'):
|
||||
assert forbidden not in flat
|
||||
|
||||
@@ -569,6 +569,33 @@ class TestHTTPScenarios:
|
||||
await manager.send({'query_id': 'test'})
|
||||
|
||||
|
||||
class TestTelemetryManagedRuntimeAuthentication:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_includes_managed_runtime_token_header(self):
|
||||
telemetry = get_telemetry_module()
|
||||
mock_app = Mock()
|
||||
mock_app.logger = Mock()
|
||||
manager = telemetry.TelemetryManager(mock_app)
|
||||
manager.telemetry_config = {'url': 'https://example.com'}
|
||||
captured = {}
|
||||
|
||||
async def mock_post(url, json, headers):
|
||||
captured['headers'] = headers
|
||||
return Mock(status_code=200, text='', json=Mock(return_value={'code': 0}))
|
||||
|
||||
mock_client = Mock()
|
||||
mock_client.post = mock_post
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
with (
|
||||
patch.dict('os.environ', {'LANGBOT_TELEMETRY_INGEST_TOKEN': 'managed-runtime-secret'}),
|
||||
patch.object(httpx, 'AsyncClient', return_value=mock_client),
|
||||
):
|
||||
await manager.send({'event_type': 'instance_heartbeat'})
|
||||
|
||||
assert captured['headers'] == {'X-LangBot-Telemetry-Token': 'managed-runtime-secret'}
|
||||
|
||||
|
||||
class TestStartSendTask:
|
||||
"""Tests for start_send_task() method."""
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def test_standard_oss_instance_id_aligns_to_embedded_uuid():
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
|
||||
instance_uuid = "a711d9e4-0953-443f-a0e9-7dd50193a79f"
|
||||
|
||||
assert workspace_uuid_from_instance_id(instance_uuid) == instance_uuid
|
||||
assert workspace_uuid_from_instance_id(f"instance_{instance_uuid}") == instance_uuid
|
||||
|
||||
|
||||
def test_custom_legacy_instance_id_maps_to_stable_valid_uuid():
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
|
||||
first = workspace_uuid_from_instance_id("instance_migration_test")
|
||||
second = workspace_uuid_from_instance_id("instance_migration_test")
|
||||
|
||||
assert first == second
|
||||
assert str(uuid.UUID(first)) == first
|
||||
|
||||
|
||||
def test_query_telemetry_identity_uses_execution_workspace_only():
|
||||
from langbot.pkg.telemetry.identity import workspace_identity
|
||||
|
||||
identity = workspace_identity(SimpleNamespace(workspace_uuid="workspace-a", instance_uuid="instance-a"))
|
||||
|
||||
assert identity == {"workspace_uuid": "workspace-a"}
|
||||
@@ -26,6 +26,7 @@ from langbot.pkg.workspace import (
|
||||
WorkspaceOwnerAlreadyExistsError,
|
||||
WorkspaceService,
|
||||
)
|
||||
from langbot.pkg.workspace.identity import workspace_uuid_from_instance_id
|
||||
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
|
||||
|
||||
|
||||
@@ -113,6 +114,7 @@ async def test_ensure_singleton_workspace_is_idempotent(workspace_test_context):
|
||||
first = await service.ensure_singleton_workspace()
|
||||
second = await service.ensure_singleton_workspace()
|
||||
|
||||
assert first.uuid == workspace_uuid_from_instance_id('instance_service_test')
|
||||
assert second.uuid == first.uuid
|
||||
async with session_factory() as session:
|
||||
assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(Workspace)) == 1
|
||||
|
||||
Reference in New Issue
Block a user