vibesurf 0.1.31__py3-none-any.whl → 0.1.33__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. vibe_surf/_version.py +2 -2
  2. vibe_surf/agents/browser_use_agent.py +1 -1
  3. vibe_surf/agents/prompts/vibe_surf_prompt.py +6 -0
  4. vibe_surf/agents/report_writer_agent.py +50 -0
  5. vibe_surf/agents/vibe_surf_agent.py +56 -1
  6. vibe_surf/backend/api/composio.py +952 -0
  7. vibe_surf/backend/database/migrations/v005_add_composio_integration.sql +33 -0
  8. vibe_surf/backend/database/migrations/v006_add_credentials_table.sql +26 -0
  9. vibe_surf/backend/database/models.py +53 -1
  10. vibe_surf/backend/database/queries.py +312 -2
  11. vibe_surf/backend/main.py +28 -0
  12. vibe_surf/backend/shared_state.py +123 -9
  13. vibe_surf/chrome_extension/scripts/api-client.js +32 -0
  14. vibe_surf/chrome_extension/scripts/settings-manager.js +954 -1
  15. vibe_surf/chrome_extension/sidepanel.html +190 -0
  16. vibe_surf/chrome_extension/styles/settings-integrations.css +927 -0
  17. vibe_surf/chrome_extension/styles/settings-modal.css +7 -3
  18. vibe_surf/chrome_extension/styles/settings-responsive.css +37 -5
  19. vibe_surf/cli.py +98 -3
  20. vibe_surf/telemetry/__init__.py +60 -0
  21. vibe_surf/telemetry/service.py +112 -0
  22. vibe_surf/telemetry/views.py +156 -0
  23. vibe_surf/tools/browser_use_tools.py +90 -90
  24. vibe_surf/tools/composio_client.py +456 -0
  25. vibe_surf/tools/mcp_client.py +21 -2
  26. vibe_surf/tools/vibesurf_tools.py +290 -87
  27. vibe_surf/tools/views.py +16 -0
  28. vibe_surf/tools/website_api/youtube/client.py +35 -13
  29. vibe_surf/utils.py +13 -0
  30. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/METADATA +11 -9
  31. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/RECORD +35 -26
  32. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/WHEEL +0 -0
  33. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/entry_points.txt +0 -0
  34. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/licenses/LICENSE +0 -0
  35. {vibesurf-0.1.31.dist-info → vibesurf-0.1.33.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,33 @@
1
+ -- Migration: v005_add_composio_integration.sql
2
+ -- Description: Add composio_toolkits table for Composio integration management
3
+ -- Version: 0.0.5
4
+
5
+ -- Enable foreign keys
6
+ PRAGMA foreign_keys = ON;
7
+
8
+ -- Create Composio Toolkits table
9
+ CREATE TABLE IF NOT EXISTS composio_toolkits (
10
+ id VARCHAR(36) NOT NULL PRIMARY KEY,
11
+ name VARCHAR(100) NOT NULL,
12
+ slug VARCHAR(100) NOT NULL UNIQUE,
13
+ description TEXT,
14
+ logo TEXT,
15
+ app_url TEXT,
16
+ enabled BOOLEAN NOT NULL DEFAULT 0,
17
+ tools TEXT,
18
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
19
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
20
+ );
21
+
22
+ -- Create indexes for composio_toolkits
23
+ CREATE INDEX IF NOT EXISTS idx_composio_toolkits_name ON composio_toolkits(name);
24
+ CREATE INDEX IF NOT EXISTS idx_composio_toolkits_slug ON composio_toolkits(slug);
25
+ CREATE INDEX IF NOT EXISTS idx_composio_toolkits_enabled ON composio_toolkits(enabled);
26
+
27
+ -- Create trigger for automatic timestamp updates
28
+ CREATE TRIGGER IF NOT EXISTS update_composio_toolkits_updated_at
29
+ AFTER UPDATE ON composio_toolkits
30
+ FOR EACH ROW
31
+ BEGIN
32
+ UPDATE composio_toolkits SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
33
+ END;
@@ -0,0 +1,26 @@
1
+ -- Migration v006: Add credentials table for storing encrypted API keys
2
+ -- Created: 2025-01-10
3
+
4
+ -- Enable foreign keys
5
+ PRAGMA foreign_keys = ON;
6
+
7
+ -- Create credentials table
8
+ CREATE TABLE IF NOT EXISTS credentials (
9
+ id VARCHAR(36) PRIMARY KEY,
10
+ key_name VARCHAR(100) NOT NULL UNIQUE,
11
+ encrypted_value TEXT,
12
+ description TEXT,
13
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
14
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
15
+ );
16
+
17
+ -- Create index for faster lookups
18
+ CREATE INDEX IF NOT EXISTS idx_credentials_key_name ON credentials(key_name);
19
+
20
+ -- Create trigger for automatic timestamp updates
21
+ CREATE TRIGGER IF NOT EXISTS update_credentials_updated_at
22
+ AFTER UPDATE ON credentials
23
+ FOR EACH ROW
24
+ BEGIN
25
+ UPDATE credentials SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
26
+ END;
@@ -189,6 +189,50 @@ class McpProfile(Base):
189
189
  def __repr__(self):
190
190
  return f"<McpProfile(display_name={self.display_name}, server_name={self.mcp_server_name}, active={self.is_active})>"
191
191
 
192
+ class ComposioToolkit(Base):
193
+ """Composio Toolkit model for managing Composio app integrations"""
194
+ __tablename__ = 'composio_toolkits'
195
+
196
+ # Primary identifier
197
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
198
+ name = Column(String(100), nullable=False)
199
+ slug = Column(String(100), nullable=False, unique=True)
200
+
201
+ # Toolkit information
202
+ description = Column(Text, nullable=True)
203
+ logo = Column(Text, nullable=True) # URL to logo
204
+ app_url = Column(Text, nullable=True)
205
+
206
+ # Configuration
207
+ enabled = Column(Boolean, default=False, nullable=False)
208
+ tools = Column(Text, nullable=True) # JSON string storing tool_name: 0|1 mapping
209
+
210
+ # Timestamps
211
+ created_at = Column(DateTime, nullable=False, default=func.now())
212
+ updated_at = Column(DateTime, nullable=False, default=func.now(), onupdate=func.now())
213
+
214
+ def __repr__(self):
215
+ return f"<ComposioToolkit(name={self.name}, slug={self.slug}, enabled={self.enabled})>"
216
+
217
+ class Credential(Base):
218
+ """Credential model for storing encrypted API keys and other sensitive data"""
219
+ __tablename__ = 'credentials'
220
+
221
+ # Primary identifier
222
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
223
+ key_name = Column(String(100), nullable=False, unique=True) # e.g., "COMPOSIO_API_KEY"
224
+ encrypted_value = Column(Text, nullable=True) # Encrypted value using MAC address
225
+
226
+ # Metadata
227
+ description = Column(Text, nullable=True)
228
+
229
+ # Timestamps
230
+ created_at = Column(DateTime, nullable=False, default=func.now())
231
+ updated_at = Column(DateTime, nullable=False, default=func.now(), onupdate=func.now())
232
+
233
+ def __repr__(self):
234
+ return f"<Credential(key_name={self.key_name})>"
235
+
192
236
  Index('idx_uploaded_files_session_time', UploadedFile.session_id, UploadedFile.upload_time)
193
237
  Index('idx_uploaded_files_active', UploadedFile.is_deleted, UploadedFile.upload_time)
194
238
  Index('idx_uploaded_files_filename', UploadedFile.original_filename)
@@ -201,4 +245,12 @@ Index('idx_mcp_profiles_active', McpProfile.is_active)
201
245
  # Voice Profile indexes
202
246
  Index('idx_voice_profiles_name', VoiceProfile.voice_profile_name)
203
247
  Index('idx_voice_profiles_type', VoiceProfile.voice_model_type)
204
- Index('idx_voice_profiles_active', VoiceProfile.is_active)
248
+ Index('idx_voice_profiles_active', VoiceProfile.is_active)
249
+
250
+ # Composio Toolkit indexes
251
+ Index('idx_composio_toolkits_name', ComposioToolkit.name)
252
+ Index('idx_composio_toolkits_slug', ComposioToolkit.slug)
253
+ Index('idx_composio_toolkits_enabled', ComposioToolkit.enabled)
254
+
255
+ # Credential indexes
256
+ Index('idx_credentials_key_name', Credential.key_name)
@@ -3,12 +3,12 @@ Database Query Operations for VibeSurf Backend - With LLM Profile Management
3
3
 
4
4
  Centralized database operations for Task and LLMProfile tables.
5
5
  """
6
-
6
+ import pdb
7
7
  from typing import List, Optional, Dict, Any
8
8
  from sqlalchemy.ext.asyncio import AsyncSession
9
9
  from sqlalchemy import select, update, delete, func, desc, and_, or_
10
10
  from sqlalchemy.orm import selectinload
11
- from .models import Task, TaskStatus, LLMProfile, UploadedFile, McpProfile, VoiceProfile, VoiceModelType
11
+ from .models import Task, TaskStatus, LLMProfile, UploadedFile, McpProfile, VoiceProfile, VoiceModelType, ComposioToolkit, Credential
12
12
  from ..utils.encryption import encrypt_api_key, decrypt_api_key
13
13
  import logging
14
14
  import json
@@ -1117,3 +1117,313 @@ class VoiceProfileQueries:
1117
1117
  except Exception as e:
1118
1118
  logger.error(f"Failed to update last_used for Voice profile {voice_profile_name}: {e}")
1119
1119
  raise
1120
+
1121
+
1122
+ class ComposioToolkitQueries:
1123
+ """Query operations for ComposioToolkit model"""
1124
+
1125
+ @staticmethod
1126
+ async def create_toolkit(
1127
+ db: AsyncSession,
1128
+ name: str,
1129
+ slug: str,
1130
+ description: Optional[str] = None,
1131
+ logo: Optional[str] = None,
1132
+ app_url: Optional[str] = None,
1133
+ enabled: bool = False,
1134
+ tools: Optional[str] = None
1135
+ ) -> Dict[str, Any]:
1136
+ """Create a new Composio toolkit"""
1137
+ try:
1138
+ toolkit = ComposioToolkit(
1139
+ name=name,
1140
+ slug=slug,
1141
+ description=description,
1142
+ logo=logo,
1143
+ app_url=app_url,
1144
+ enabled=enabled,
1145
+ tools=tools
1146
+ )
1147
+
1148
+ db.add(toolkit)
1149
+ await db.flush()
1150
+ await db.refresh(toolkit)
1151
+
1152
+ # Extract data immediately to avoid greenlet issues
1153
+ toolkit_data = {
1154
+ "id": toolkit.id,
1155
+ "name": toolkit.name,
1156
+ "slug": toolkit.slug,
1157
+ "description": toolkit.description,
1158
+ "logo": toolkit.logo,
1159
+ "app_url": toolkit.app_url,
1160
+ "enabled": toolkit.enabled,
1161
+ "tools": toolkit.tools,
1162
+ "created_at": toolkit.created_at,
1163
+ "updated_at": toolkit.updated_at
1164
+ }
1165
+
1166
+ return toolkit_data
1167
+ except Exception as e:
1168
+ logger.error(f"Failed to create Composio toolkit {name}: {e}")
1169
+ raise
1170
+
1171
+ @staticmethod
1172
+ async def get_toolkit(db: AsyncSession, toolkit_id: str) -> Optional[ComposioToolkit]:
1173
+ """Get Composio toolkit by ID"""
1174
+ try:
1175
+ result = await db.execute(
1176
+ select(ComposioToolkit).where(ComposioToolkit.id == toolkit_id)
1177
+ )
1178
+ toolkit = result.scalar_one_or_none()
1179
+ if toolkit:
1180
+ # Ensure all attributes are loaded by accessing them
1181
+ _ = (toolkit.id, toolkit.created_at, toolkit.updated_at, toolkit.enabled)
1182
+ return toolkit
1183
+ except Exception as e:
1184
+ logger.error(f"Failed to get Composio toolkit {toolkit_id}: {e}")
1185
+ raise
1186
+
1187
+ @staticmethod
1188
+ async def get_toolkit_by_slug(db: AsyncSession, slug: str) -> Optional[ComposioToolkit]:
1189
+ """Get Composio toolkit by slug"""
1190
+ try:
1191
+ result = await db.execute(
1192
+ select(ComposioToolkit).where(ComposioToolkit.slug == slug)
1193
+ )
1194
+ toolkit = result.scalar_one_or_none()
1195
+ if toolkit:
1196
+ _ = (toolkit.id, toolkit.created_at, toolkit.updated_at, toolkit.enabled)
1197
+ return toolkit
1198
+ except Exception as e:
1199
+ logger.error(f"Failed to get Composio toolkit by slug {slug}: {e}")
1200
+ raise
1201
+
1202
+ @staticmethod
1203
+ async def list_toolkits(
1204
+ db: AsyncSession,
1205
+ enabled_only: bool = False,
1206
+ limit: int = -1,
1207
+ offset: int = 0
1208
+ ) -> List[ComposioToolkit]:
1209
+ """List Composio toolkits"""
1210
+ try:
1211
+ query = select(ComposioToolkit)
1212
+
1213
+ if enabled_only:
1214
+ query = query.where(ComposioToolkit.enabled == True)
1215
+
1216
+ # Handle -1 as "get all records"
1217
+ if limit != -1:
1218
+ query = query.limit(limit)
1219
+
1220
+ # Always apply offset if provided
1221
+ if offset > 0:
1222
+ query = query.offset(offset)
1223
+
1224
+ result = await db.execute(query)
1225
+ toolkits = result.scalars().all()
1226
+
1227
+ # Ensure all attributes are loaded for each toolkit
1228
+ for toolkit in toolkits:
1229
+ _ = (toolkit.id, toolkit.created_at, toolkit.updated_at, toolkit.enabled)
1230
+
1231
+ return toolkits
1232
+ except Exception as e:
1233
+ logger.error(f"Failed to list Composio toolkits: {e}")
1234
+ raise
1235
+
1236
+ @staticmethod
1237
+ async def get_enabled_toolkits(db: AsyncSession) -> List[ComposioToolkit]:
1238
+ """Get all enabled Composio toolkits"""
1239
+ try:
1240
+ result = await db.execute(
1241
+ select(ComposioToolkit).where(ComposioToolkit.enabled == True)
1242
+ )
1243
+ toolkits = result.scalars().all()
1244
+
1245
+ # Ensure all attributes are loaded for each toolkit
1246
+ for toolkit in toolkits:
1247
+ _ = (toolkit.id, toolkit.created_at, toolkit.updated_at, toolkit.enabled)
1248
+
1249
+ return toolkits
1250
+ except Exception as e:
1251
+ logger.error(f"Failed to get enabled Composio toolkits: {e}")
1252
+ raise
1253
+
1254
+ @staticmethod
1255
+ async def update_toolkit(
1256
+ db: AsyncSession,
1257
+ toolkit_id: str,
1258
+ updates: Dict[str, Any]
1259
+ ) -> bool:
1260
+ """Update Composio toolkit"""
1261
+ try:
1262
+ result = await db.execute(
1263
+ update(ComposioToolkit)
1264
+ .where(ComposioToolkit.id == toolkit_id)
1265
+ .values(**updates)
1266
+ )
1267
+
1268
+ return result.rowcount > 0
1269
+ except Exception as e:
1270
+ logger.error(f"Failed to update Composio toolkit {toolkit_id}: {e}")
1271
+ raise
1272
+
1273
+ @staticmethod
1274
+ async def update_toolkit_by_slug(
1275
+ db: AsyncSession,
1276
+ slug: str,
1277
+ updates: Dict[str, Any]
1278
+ ) -> bool:
1279
+ """Update Composio toolkit by slug"""
1280
+ try:
1281
+ result = await db.execute(
1282
+ update(ComposioToolkit)
1283
+ .where(ComposioToolkit.slug == slug)
1284
+ .values(**updates)
1285
+ )
1286
+
1287
+ return result.rowcount > 0
1288
+ except Exception as e:
1289
+ logger.error(f"Failed to update Composio toolkit by slug {slug}: {e}")
1290
+ raise
1291
+
1292
+ @staticmethod
1293
+ async def delete_toolkit(db: AsyncSession, toolkit_id: str) -> bool:
1294
+ """Delete Composio toolkit"""
1295
+ try:
1296
+ result = await db.execute(
1297
+ delete(ComposioToolkit).where(ComposioToolkit.id == toolkit_id)
1298
+ )
1299
+ return result.rowcount > 0
1300
+ except Exception as e:
1301
+ logger.error(f"Failed to delete Composio toolkit {toolkit_id}: {e}")
1302
+ raise
1303
+
1304
+ @staticmethod
1305
+ async def delete_toolkit_by_slug(db: AsyncSession, slug: str) -> bool:
1306
+ """Delete Composio toolkit by slug"""
1307
+ try:
1308
+ result = await db.execute(
1309
+ delete(ComposioToolkit).where(ComposioToolkit.slug == slug)
1310
+ )
1311
+ return result.rowcount > 0
1312
+ except Exception as e:
1313
+ logger.error(f"Failed to delete Composio toolkit by slug {slug}: {e}")
1314
+ raise
1315
+
1316
+ @staticmethod
1317
+ async def toggle_toolkit_enabled(db: AsyncSession, toolkit_id: str, enabled: bool) -> bool:
1318
+ """Toggle toolkit enabled status"""
1319
+ try:
1320
+ result = await db.execute(
1321
+ update(ComposioToolkit)
1322
+ .where(ComposioToolkit.id == toolkit_id)
1323
+ .values(enabled=enabled)
1324
+ )
1325
+ return result.rowcount > 0
1326
+ except Exception as e:
1327
+ logger.error(f"Failed to toggle Composio toolkit {toolkit_id} enabled status: {e}")
1328
+ raise
1329
+
1330
+ @staticmethod
1331
+ async def update_toolkit_tools(db: AsyncSession, toolkit_id: str, tools: str) -> bool:
1332
+ """Update toolkit tools configuration"""
1333
+ try:
1334
+ result = await db.execute(
1335
+ update(ComposioToolkit)
1336
+ .where(ComposioToolkit.id == toolkit_id)
1337
+ .values(tools=tools)
1338
+ )
1339
+ return result.rowcount > 0
1340
+ except Exception as e:
1341
+ logger.error(f"Failed to update Composio toolkit {toolkit_id} tools: {e}")
1342
+ raise
1343
+
1344
+
1345
+ class CredentialQueries:
1346
+ """Query operations for Credential model"""
1347
+
1348
+ @staticmethod
1349
+ async def get_credential(db: AsyncSession, key_name: str) -> Optional[str]:
1350
+ """Get decrypted credential value by key name"""
1351
+ try:
1352
+ result = await db.execute(
1353
+ select(Credential).where(Credential.key_name == key_name)
1354
+ )
1355
+ credential = result.scalar_one_or_none()
1356
+ if not credential or not credential.encrypted_value:
1357
+ return None
1358
+
1359
+ # Decrypt the value
1360
+ decrypted_value = decrypt_api_key(credential.encrypted_value)
1361
+ return decrypted_value
1362
+
1363
+ except Exception as e:
1364
+ logger.error(f"Failed to get credential {key_name}: {e}")
1365
+ return None
1366
+
1367
+ @staticmethod
1368
+ async def store_credential(db: AsyncSession, key_name: str, value: str, description: Optional[str] = None) -> bool:
1369
+ """Store encrypted credential"""
1370
+ try:
1371
+ # Encrypt the value
1372
+ encrypted_value = encrypt_api_key(value)
1373
+
1374
+ # Check if credential exists
1375
+ result = await db.execute(
1376
+ select(Credential).where(Credential.key_name == key_name)
1377
+ )
1378
+ existing_credential = result.scalar_one_or_none()
1379
+
1380
+ if existing_credential:
1381
+ # Update existing credential
1382
+ await db.execute(
1383
+ update(Credential)
1384
+ .where(Credential.key_name == key_name)
1385
+ .values(
1386
+ encrypted_value=encrypted_value,
1387
+ description=description,
1388
+ updated_at=func.now()
1389
+ )
1390
+ )
1391
+ else:
1392
+ # Create new credential
1393
+ credential = Credential(
1394
+ key_name=key_name,
1395
+ encrypted_value=encrypted_value,
1396
+ description=description
1397
+ )
1398
+ db.add(credential)
1399
+
1400
+ await db.flush()
1401
+ return True
1402
+
1403
+ except Exception as e:
1404
+ logger.error(f"Failed to store credential {key_name}: {e}")
1405
+ return False
1406
+
1407
+ @staticmethod
1408
+ async def delete_credential(db: AsyncSession, key_name: str) -> bool:
1409
+ """Delete credential"""
1410
+ try:
1411
+ result = await db.execute(
1412
+ delete(Credential).where(Credential.key_name == key_name)
1413
+ )
1414
+ return result.rowcount > 0
1415
+ except Exception as e:
1416
+ logger.error(f"Failed to delete credential {key_name}: {e}")
1417
+ return False
1418
+
1419
+ @staticmethod
1420
+ async def list_credential_names(db: AsyncSession) -> List[str]:
1421
+ """List all credential key names (for administrative purposes)"""
1422
+ try:
1423
+ result = await db.execute(
1424
+ select(Credential.key_name).order_by(Credential.created_at)
1425
+ )
1426
+ return [row[0] for row in result.all()]
1427
+ except Exception as e:
1428
+ logger.error(f"Failed to list credential names: {e}")
1429
+ return []
vibe_surf/backend/main.py CHANGED
@@ -22,6 +22,7 @@ from .api.config import router as config_router
22
22
  from .api.browser import router as browser_router
23
23
  from .api.voices import router as voices_router
24
24
  from .api.agent import router as agent_router
25
+ from .api.composio import router as composio_router
25
26
 
26
27
  # Import shared state
27
28
  from . import shared_state
@@ -29,6 +30,8 @@ from . import shared_state
29
30
  # Configure logging
30
31
 
31
32
  from vibe_surf.logger import get_logger
33
+ from vibe_surf.telemetry.service import ProductTelemetry
34
+ from vibe_surf.telemetry.views import BackendTelemetryEvent
32
35
 
33
36
  logger = get_logger(__name__)
34
37
 
@@ -55,6 +58,7 @@ app.include_router(config_router, prefix="/api", tags=["config"])
55
58
  app.include_router(browser_router, prefix="/api", tags=["browser"])
56
59
  app.include_router(voices_router, prefix="/api", tags=["voices"])
57
60
  app.include_router(agent_router, prefix="/api", tags=["agent"])
61
+ app.include_router(composio_router, prefix="/api", tags=["composio"])
58
62
 
59
63
  # Global variable to control browser monitoring task
60
64
  browser_monitor_task = None
@@ -110,6 +114,15 @@ async def startup_event():
110
114
  """Initialize database and VibeSurf components on startup"""
111
115
  global browser_monitor_task
112
116
 
117
+ # Initialize telemetry and capture startup event
118
+ telemetry = ProductTelemetry()
119
+ import vibe_surf
120
+ startup_event = BackendTelemetryEvent(
121
+ version=vibe_surf.__version__,
122
+ action='startup'
123
+ )
124
+ telemetry.capture(startup_event)
125
+
113
126
  # Initialize VibeSurf components and update shared state
114
127
  await shared_state.initialize_vibesurf_components()
115
128
 
@@ -118,6 +131,9 @@ async def startup_event():
118
131
  logger.info("🔍 Started browser connection monitor")
119
132
 
120
133
  logger.info("🚀 VibeSurf Backend API started with single-task execution model")
134
+
135
+ # Flush telemetry
136
+ telemetry.flush()
121
137
 
122
138
  @app.on_event("shutdown")
123
139
  async def shutdown_event():
@@ -126,6 +142,15 @@ async def shutdown_event():
126
142
 
127
143
  logger.info("🛑 Starting graceful shutdown...")
128
144
 
145
+ # Capture telemetry shutdown event
146
+ telemetry = ProductTelemetry()
147
+ import vibe_surf
148
+ shutdown_event = BackendTelemetryEvent(
149
+ version=vibe_surf.__version__,
150
+ action='shutdown'
151
+ )
152
+ telemetry.capture(shutdown_event)
153
+
129
154
  # Cancel browser monitor task
130
155
  if browser_monitor_task and not browser_monitor_task.done():
131
156
  browser_monitor_task.cancel()
@@ -153,6 +178,9 @@ async def shutdown_event():
153
178
  logger.error(f"❌ Error closing database manager: {e}")
154
179
 
155
180
  logger.info("🛑 VibeSurf Backend API stopped")
181
+
182
+ # Flush telemetry before shutdown
183
+ telemetry.flush()
156
184
 
157
185
  # Health check endpoint
158
186
  @app.get("/health")