mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(otel): added otel, persist settings to db, present user with the telemetry preferences & add privacy tab to settings (#318)
* feat(otel): added otel, persist settings to db, present user with the telemetry preferences & add privacy tab to settings * updated telemetry endpoint * add protected subdomains for chat deploy * removed unused dependencies * add execution telemetry logs for workflow-level and block-level logs, acknowledged PR comments
This commit is contained in:
+9
-1
@@ -65,4 +65,12 @@ docs/.contentlayer
|
||||
docs/.content-collections
|
||||
|
||||
# database instantiation
|
||||
**/postgres_data/
|
||||
**/postgres_data/
|
||||
|
||||
# file uploads
|
||||
uploads/
|
||||
|
||||
# collector configuration
|
||||
collector-config.yaml
|
||||
docker-compose.collector.yml
|
||||
start-collector.sh
|
||||
|
||||
@@ -162,6 +162,29 @@ describe('Subdomain Validation API Route', () => {
|
||||
subdomain: 'available-subdomain',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return available=false when subdomain is reserved', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate?subdomain=telemetry')
|
||||
|
||||
const { GET } = await import('./route')
|
||||
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('available', false)
|
||||
expect(data).toHaveProperty('error', 'This subdomain is reserved')
|
||||
expect(mockNextResponseJson).toHaveBeenCalledWith(
|
||||
{ available: false, error: 'This subdomain is reserved' },
|
||||
{ status: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
it('should return available=false when subdomain is already in use', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
|
||||
@@ -34,6 +34,18 @@ export async function GET(request: Request) {
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Protect reserved subdomains
|
||||
const reservedSubdomains = ['telemetry', 'docs', 'api', 'admin', 'www', 'app', 'auth', 'blog', 'help', 'support'];
|
||||
if (reservedSubdomains.includes(subdomain)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
available: false,
|
||||
error: 'This subdomain is reserved'
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Query database to see if subdomain already exists
|
||||
const existingDeployment = await db
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('TelemetryAPI')
|
||||
|
||||
const ALLOWED_CATEGORIES = [
|
||||
'page_view',
|
||||
'feature_usage',
|
||||
'performance',
|
||||
'error',
|
||||
'workflow',
|
||||
'consent',
|
||||
]
|
||||
|
||||
const DEFAULT_TIMEOUT = 5000 // 5 seconds timeout
|
||||
|
||||
/**
|
||||
* Validates telemetry data to ensure it doesn't contain sensitive information
|
||||
*/
|
||||
function validateTelemetryData(data: any): boolean {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!data.category || !data.action) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!ALLOWED_CATEGORIES.includes(data.category)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const jsonStr = JSON.stringify(data).toLowerCase()
|
||||
const sensitivePatterns = [
|
||||
/password/,
|
||||
/token/,
|
||||
/secret/,
|
||||
/key/,
|
||||
/auth/,
|
||||
/credential/,
|
||||
/private/,
|
||||
]
|
||||
|
||||
return !sensitivePatterns.some(pattern => pattern.test(jsonStr))
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely converts a value to string, handling undefined and null values
|
||||
*/
|
||||
function safeStringValue(value: any): string {
|
||||
if (value === undefined || value === null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return String(value)
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a safe attribute object for OpenTelemetry
|
||||
*/
|
||||
function createSafeAttributes(data: Record<string, any>): Array<{key: string, value: {stringValue: string}}> {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const attributes: Array<{key: string, value: {stringValue: string}}> = []
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && key) {
|
||||
attributes.push({
|
||||
key,
|
||||
value: { stringValue: safeStringValue(value) }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards telemetry data to OpenTelemetry collector
|
||||
*/
|
||||
async function forwardToCollector(data: any): Promise<boolean> {
|
||||
if (!data || typeof data !== 'object') {
|
||||
logger.error('Invalid telemetry data format')
|
||||
return false
|
||||
}
|
||||
|
||||
const endpoint = process.env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces'
|
||||
const timeout = parseInt(process.env.TELEMETRY_TIMEOUT || '') || DEFAULT_TIMEOUT
|
||||
|
||||
try {
|
||||
const timestamp = Date.now() * 1000000
|
||||
|
||||
const safeAttrs = createSafeAttributes(data)
|
||||
|
||||
const serviceAttrs = [
|
||||
{ key: 'service.name', value: { stringValue: 'sim-studio' } },
|
||||
{ key: 'service.version', value: { stringValue: process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0' } },
|
||||
{ key: 'deployment.environment', value: { stringValue: process.env.NODE_ENV || 'production' } }
|
||||
]
|
||||
|
||||
const spanName = data.category && data.action ? `${data.category}.${data.action}` : 'telemetry.event'
|
||||
|
||||
const payload = {
|
||||
resourceSpans: [{
|
||||
resource: {
|
||||
attributes: serviceAttrs
|
||||
},
|
||||
instrumentationLibrarySpans: [{
|
||||
spans: [{
|
||||
name: spanName,
|
||||
kind: 1,
|
||||
startTimeUnixNano: timestamp,
|
||||
endTimeUnixNano: timestamp + 1000000,
|
||||
attributes: safeAttrs
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
}
|
||||
|
||||
// Safe debug log of the payload structure without sensitive data
|
||||
logger.debug('Preparing to send telemetry payload', {
|
||||
endpoint,
|
||||
hasAttributes: safeAttrs.length > 0,
|
||||
attributeCount: safeAttrs.length
|
||||
})
|
||||
|
||||
// Create explicit AbortController for timeout
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, options)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('Telemetry collector returned error', {
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (fetchError) {
|
||||
clearTimeout(timeoutId)
|
||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
||||
logger.error('Telemetry request timed out', { endpoint })
|
||||
} else {
|
||||
logger.error('Failed to send telemetry to collector', fetchError)
|
||||
}
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error preparing telemetry payload', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint that receives telemetry events and forwards them to OpenTelemetry collector
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
let eventData
|
||||
try {
|
||||
eventData = await req.json()
|
||||
} catch (parseError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid JSON in request body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!validateTelemetryData(eventData)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid telemetry data format or contains sensitive information' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const forwarded = await forwardToCollector(eventData)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
forwarded
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error processing telemetry event', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process telemetry event' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,87 @@ import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { db } from '@/db'
|
||||
import { settings } from '@/db/schema'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('SettingsAPI')
|
||||
const logger = createLogger('UserSettingsAPI')
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
userId: z.string(),
|
||||
isAutoConnectEnabled: z.boolean().default(true),
|
||||
theme: z.enum(['system', 'light', 'dark']).optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
autoConnect: z.boolean().optional(),
|
||||
autoFillEnvVars: z.boolean().optional(),
|
||||
telemetryEnabled: z.boolean().optional(),
|
||||
telemetryNotifiedUser: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function GET() {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized settings access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const result = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (!result.length) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
// Return default values
|
||||
theme: 'system',
|
||||
debugMode: false,
|
||||
autoConnect: true,
|
||||
autoFillEnvVars: true,
|
||||
telemetryEnabled: true,
|
||||
telemetryNotifiedUser: false,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
const userSettings = result[0]
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
theme: userSettings.theme,
|
||||
debugMode: userSettings.debugMode,
|
||||
autoConnect: userSettings.autoConnect,
|
||||
autoFillEnvVars: userSettings.autoFillEnvVars,
|
||||
telemetryEnabled: userSettings.telemetryEnabled,
|
||||
telemetryNotifiedUser: userSettings.telemetryNotifiedUser,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Settings fetch error`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized settings update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await request.json()
|
||||
|
||||
try {
|
||||
const { userId, isAutoConnectEnabled } = SettingsSchema.parse(body)
|
||||
const validatedData = SettingsSchema.parse(body)
|
||||
|
||||
// Store the settings
|
||||
await db
|
||||
@@ -28,13 +93,13 @@ export async function POST(request: Request) {
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
general: { isAutoConnectEnabled },
|
||||
...validatedData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [settings.userId],
|
||||
set: {
|
||||
general: { isAutoConnectEnabled },
|
||||
...validatedData,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
@@ -56,46 +121,4 @@ export async function POST(request: Request) {
|
||||
logger.error(`[${requestId}] Settings update error`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const userId = searchParams.get('userId')
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Missing userId parameter`)
|
||||
return NextResponse.json({ error: 'userId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await db.select().from(settings).where(eq(settings.userId, userId)).limit(1)
|
||||
|
||||
if (!result.length) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
isAutoConnectEnabled: true, // Return default values
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
|
||||
const generalSettings = result[0].general as {
|
||||
isAutoConnectEnabled: boolean
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
isAutoConnectEnabled: generalSettings.isAutoConnectEnabled,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Settings fetch error`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -1,8 +1,8 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import './globals.css'
|
||||
import { ZoomPrevention } from './zoom-prevention'
|
||||
import { TelemetryConsentDialog } from '@/app/telemetry-consent-dialog'
|
||||
|
||||
const logger = createLogger('RootLayout')
|
||||
|
||||
@@ -20,9 +20,7 @@ const BROWSER_EXTENSION_ATTRIBUTES = [
|
||||
if (typeof window !== 'undefined') {
|
||||
const originalError = console.error
|
||||
console.error = (...args) => {
|
||||
// Check if it's a hydration error
|
||||
if (args[0].includes('Hydration')) {
|
||||
// Check if the error is related to browser extensions
|
||||
const isExtensionError = BROWSER_EXTENSION_ATTRIBUTES.some((attr) =>
|
||||
args.some((arg) => typeof arg === 'string' && arg.includes(attr))
|
||||
)
|
||||
@@ -151,7 +149,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<ZoomPrevention />
|
||||
<SpeedInsights />
|
||||
<TelemetryConsentDialog />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SIM_TELEMETRY_ENABLED?: boolean
|
||||
__SIM_TRACK_EVENT?: (eventName: string, properties?: Record<string, any>) => void
|
||||
}
|
||||
}
|
||||
|
||||
const logger = createLogger('TelemetryConsentDialog')
|
||||
|
||||
const trackEvent = (eventName: string, properties?: Record<string, any>) => {
|
||||
if (typeof window !== 'undefined' && window.__SIM_TELEMETRY_ENABLED) {
|
||||
try {
|
||||
if (window.__SIM_TRACK_EVENT) {
|
||||
window.__SIM_TRACK_EVENT(eventName, properties)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to track event ${eventName}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function TelemetryConsentDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||
const telemetryEnabled = useGeneralStore(state => state.telemetryEnabled)
|
||||
const telemetryNotifiedUser = useGeneralStore(state => state.telemetryNotifiedUser)
|
||||
const setTelemetryEnabled = useGeneralStore(state => state.setTelemetryEnabled)
|
||||
const setTelemetryNotifiedUser = useGeneralStore(state => state.setTelemetryNotifiedUser)
|
||||
const loadSettings = useGeneralStore(state => state.loadSettings)
|
||||
|
||||
const hasShownDialogThisSession = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
await loadSettings(true)
|
||||
if (isMounted) {
|
||||
setSettingsLoaded(true)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load settings:', error)
|
||||
if (isMounted) {
|
||||
setSettingsLoaded(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchSettings()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return
|
||||
|
||||
logger.debug('Settings loaded state:', {
|
||||
telemetryNotifiedUser,
|
||||
telemetryEnabled,
|
||||
hasShownInSession: hasShownDialogThisSession.current
|
||||
})
|
||||
|
||||
// Only show dialog if:
|
||||
// 1. Settings are fully loaded from the database
|
||||
// 2. User has not been notified yet (according to database)
|
||||
// 3. Telemetry is currently enabled (default)
|
||||
// 4. Dialog hasn't been shown in this session already (extra protection)
|
||||
if (settingsLoaded && !telemetryNotifiedUser && telemetryEnabled && !hasShownDialogThisSession.current) {
|
||||
setOpen(true)
|
||||
hasShownDialogThisSession.current = true
|
||||
}
|
||||
}, [settingsLoaded, telemetryNotifiedUser, telemetryEnabled])
|
||||
|
||||
const handleAccept = () => {
|
||||
trackEvent('telemetry_consent_accepted', {
|
||||
source: 'consent_dialog',
|
||||
defaultEnabled: true
|
||||
})
|
||||
|
||||
setTelemetryNotifiedUser(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleDecline = () => {
|
||||
trackEvent('telemetry_consent_declined', {
|
||||
source: 'consent_dialog',
|
||||
defaultEnabled: false
|
||||
})
|
||||
|
||||
setTelemetryEnabled(false)
|
||||
setTelemetryNotifiedUser(true)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-2xl font-bold mb-2">Telemetry</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-4 text-base text-muted-foreground">
|
||||
<div>
|
||||
To help us improve Sim Studio, we collect anonymous usage
|
||||
data by default. This helps us understand which features are
|
||||
most useful and identify areas for improvement.
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="font-semibold text-foreground mb-2">We only collect:</div>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>Feature usage statistics</li>
|
||||
<li>Error reports (without personal info)</li>
|
||||
<li>Performance metrics</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="font-semibold text-foreground mb-2">We never collect:</div>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>Personal information</li>
|
||||
<li>Workflow content or outputs</li>
|
||||
<li>API keys or tokens</li>
|
||||
<li>IP addresses or location data</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground pt-2">
|
||||
You can change this setting anytime in{' '}
|
||||
<span className="font-medium">Settings → Privacy</span>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className="flex flex-col sm:flex-row gap-3 mt-4">
|
||||
<AlertDialogCancel asChild onClick={handleDecline}>
|
||||
<Button variant="outline" className="flex-1">
|
||||
Disable telemetry
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild onClick={handleAccept}>
|
||||
<Button className="flex-1">
|
||||
Continue with telemetry
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
+199
-109
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Info } from 'lucide-react'
|
||||
import { Info, AlertTriangle } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -20,10 +21,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { resetAllStores } from '@/stores'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
const TOOLTIPS = {
|
||||
debugMode: 'Enable visual debugging information during execution.',
|
||||
@@ -34,123 +37,200 @@ const TOOLTIPS = {
|
||||
|
||||
export function General() {
|
||||
const router = useRouter()
|
||||
const isAutoConnectEnabled = useGeneralStore((state) => state.isAutoConnectEnabled)
|
||||
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
|
||||
const isDebugModeEnabled = useGeneralStore((state) => state.isDebugModeEnabled)
|
||||
const toggleDebugMode = useGeneralStore((state) => state.toggleDebugMode)
|
||||
const isAutoFillEnvVarsEnabled = useGeneralStore((state) => state.isAutoFillEnvVarsEnabled)
|
||||
const toggleAutoFillEnvVars = useGeneralStore((state) => state.toggleAutoFillEnvVars)
|
||||
const theme = useGeneralStore((state) => state.theme)
|
||||
const setTheme = useGeneralStore((state) => state.setTheme)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
|
||||
const isLoading = useGeneralStore(state => state.isLoading)
|
||||
const error = useGeneralStore(state => state.error)
|
||||
const theme = useGeneralStore(state => state.theme)
|
||||
const isAutoConnectEnabled = useGeneralStore(state => state.isAutoConnectEnabled)
|
||||
const isDebugModeEnabled = useGeneralStore(state => state.isDebugModeEnabled)
|
||||
const isAutoFillEnvVarsEnabled = useGeneralStore(state => state.isAutoFillEnvVarsEnabled)
|
||||
|
||||
const setTheme = useGeneralStore(state => state.setTheme)
|
||||
const toggleAutoConnect = useGeneralStore(state => state.toggleAutoConnect)
|
||||
const toggleDebugMode = useGeneralStore(state => state.toggleDebugMode)
|
||||
const toggleAutoFillEnvVars = useGeneralStore(state => state.toggleAutoFillEnvVars)
|
||||
const loadSettings = useGeneralStore(state => state.loadSettings)
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await loadSettings(retryCount > 0)
|
||||
}
|
||||
loadData()
|
||||
}, [loadSettings, retryCount])
|
||||
|
||||
const handleThemeChange = (value: 'system' | 'light' | 'dark') => {
|
||||
setTheme(value)
|
||||
}
|
||||
|
||||
const handleDebugModeChange = (checked: boolean) => {
|
||||
if (checked !== isDebugModeEnabled) {
|
||||
toggleDebugMode()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoConnectChange = (checked: boolean) => {
|
||||
if (checked !== isAutoConnectEnabled) {
|
||||
toggleAutoConnect()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoFillEnvVarsChange = (checked: boolean) => {
|
||||
if (checked !== isAutoFillEnvVarsEnabled) {
|
||||
toggleAutoFillEnvVars()
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetData = () => {
|
||||
resetAllStores()
|
||||
router.push('/w/1') // Redirect to home page after reset
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount(prev => prev + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="flex justify-between items-center">
|
||||
<span>Failed to load settings: {error}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-[22px]">General Settings</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="theme-select" className="font-medium">
|
||||
Theme
|
||||
</Label>
|
||||
</div>
|
||||
<Select value={theme} onValueChange={setTheme}>
|
||||
<SelectTrigger id="theme-select" className="w-[180px]">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="debug-mode" className="font-medium">
|
||||
Debug mode
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 p-1 h-7"
|
||||
aria-label="Learn more about debug mode"
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px] p-3">
|
||||
<p className="text-sm">{TOOLTIPS.debugMode}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id="debug-mode"
|
||||
checked={isDebugModeEnabled}
|
||||
onCheckedChange={toggleDebugMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="auto-connect" className="font-medium">
|
||||
Auto-connect on drop
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 p-1 h-7"
|
||||
aria-label="Learn more about auto-connect feature"
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px] p-3">
|
||||
<p className="text-sm">{TOOLTIPS.autoConnect}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-connect"
|
||||
checked={isAutoConnectEnabled}
|
||||
onCheckedChange={toggleAutoConnect}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="auto-fill-env-vars" className="font-medium">
|
||||
Auto-fill environment variables
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 p-1 h-7"
|
||||
aria-label="Learn more about auto-fill environment variables"
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px] p-3">
|
||||
<p className="text-sm">{TOOLTIPS.autoFillEnvVars}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-fill-env-vars"
|
||||
checked={isAutoFillEnvVarsEnabled}
|
||||
onCheckedChange={toggleAutoFillEnvVars}
|
||||
/>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<SettingRowSkeleton />
|
||||
<SettingRowSkeleton />
|
||||
<SettingRowSkeleton />
|
||||
<SettingRowSkeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="theme-select" className="font-medium">
|
||||
Theme
|
||||
</Label>
|
||||
</div>
|
||||
<Select
|
||||
value={theme}
|
||||
onValueChange={handleThemeChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger id="theme-select" className="w-[180px]">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="debug-mode" className="font-medium">
|
||||
Debug mode
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 p-1 h-7"
|
||||
aria-label="Learn more about debug mode"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px] p-3">
|
||||
<p className="text-sm">{TOOLTIPS.debugMode}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id="debug-mode"
|
||||
checked={isDebugModeEnabled}
|
||||
onCheckedChange={handleDebugModeChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="auto-connect" className="font-medium">
|
||||
Auto-connect on drop
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 p-1 h-7"
|
||||
aria-label="Learn more about auto-connect feature"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px] p-3">
|
||||
<p className="text-sm">{TOOLTIPS.autoConnect}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-connect"
|
||||
checked={isAutoConnectEnabled}
|
||||
onCheckedChange={handleAutoConnectChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="auto-fill-env-vars" className="font-medium">
|
||||
Auto-fill environment variables
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 p-1 h-7"
|
||||
aria-label="Learn more about auto-fill environment variables"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px] p-3">
|
||||
<p className="text-sm">{TOOLTIPS.autoFillEnvVars}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-fill-env-vars"
|
||||
checked={isAutoFillEnvVarsEnabled}
|
||||
onCheckedChange={handleAutoFillEnvVarsChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,6 +246,7 @@ export function General() {
|
||||
size="sm"
|
||||
className="text-gray-500 p-1 h-7"
|
||||
aria-label="Learn more about resetting all data"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
@@ -177,7 +258,7 @@ export function General() {
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Button variant="destructive" size="sm" disabled={isLoading}>
|
||||
Reset Data
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
@@ -205,3 +286,12 @@ export function General() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingRowSkeleton = () => (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-12" />
|
||||
</div>
|
||||
)
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
const TOOLTIPS = {
|
||||
telemetry: 'We collect anonymous data about feature usage, performance, and errors to improve the application.',
|
||||
}
|
||||
|
||||
export function Privacy() {
|
||||
const isLoading = useGeneralStore(state => state.isLoading)
|
||||
const telemetryEnabled = useGeneralStore(state => state.telemetryEnabled)
|
||||
const setTelemetryEnabled = useGeneralStore(state => state.setTelemetryEnabled)
|
||||
const setTelemetryNotifiedUser = useGeneralStore(state => state.setTelemetryNotifiedUser)
|
||||
const loadSettings = useGeneralStore(state => state.loadSettings)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
const handleTelemetryToggle = (checked: boolean) => {
|
||||
setTelemetryEnabled(checked)
|
||||
|
||||
if (checked) {
|
||||
setTelemetryNotifiedUser(true)
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
category: 'consent',
|
||||
action: 'enable_from_settings',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
}).catch(() => {
|
||||
// Silently fail - this is just telemetry
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-[22px]">Privacy Settings</h2>
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<SettingRowSkeleton />
|
||||
) : (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="telemetry" className="font-medium">
|
||||
Allow anonymous telemetry
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500 p-1 h-7"
|
||||
aria-label="Learn more about telemetry data collection"
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[300px] p-3">
|
||||
<p className="text-sm">{TOOLTIPS.telemetry}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id="telemetry"
|
||||
checked={telemetryEnabled}
|
||||
onCheckedChange={handleTelemetryToggle}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We use OpenTelemetry to collect anonymous usage data to improve Sim Studio.
|
||||
All data is collected in accordance with our privacy policy, and you can opt-out at any time.
|
||||
This setting applies to your account on all devices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingRowSkeleton = () => (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-12" />
|
||||
</div>
|
||||
)
|
||||
+8
-3
@@ -1,17 +1,17 @@
|
||||
import { Key, KeyRound, KeySquare, Settings, UserCircle, CreditCard, Users } from 'lucide-react'
|
||||
import { Key, KeyRound, KeySquare, Settings, UserCircle, CreditCard, Users, Shield } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isDev } from '@/lib/environment'
|
||||
|
||||
interface SettingsNavigationProps {
|
||||
activeSection: string
|
||||
onSectionChange: (
|
||||
section: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team'
|
||||
section: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team' | 'privacy'
|
||||
) => void
|
||||
isTeam?: boolean
|
||||
}
|
||||
|
||||
type NavigationItem = {
|
||||
id: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team'
|
||||
id: 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team' | 'privacy'
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
hideInDev?: boolean
|
||||
@@ -44,6 +44,11 @@ const allNavigationItems: NavigationItem[] = [
|
||||
label: 'API Keys',
|
||||
icon: KeySquare,
|
||||
},
|
||||
{
|
||||
id: 'privacy',
|
||||
label: 'Privacy',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
id: 'subscription',
|
||||
label: 'Subscription',
|
||||
|
||||
+87
-35
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { client, useSession, useActiveOrganization } from '@/lib/auth-client'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { client, useSession, useActiveOrganization, useSubscription } from '@/lib/auth-client'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,29 +27,58 @@ const logger = createLogger('Subscription')
|
||||
|
||||
interface SubscriptionProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
cachedIsPro?: boolean
|
||||
cachedIsTeam?: boolean
|
||||
cachedUsageData?: any
|
||||
cachedSubscriptionData?: any
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const useSubscriptionData = (userId: string | null | undefined, activeOrgId: string | null | undefined) => {
|
||||
const [isPro, setIsPro] = useState<boolean>(false)
|
||||
const [isTeam, setIsTeam] = useState<boolean>(false)
|
||||
const useSubscriptionData = (
|
||||
userId: string | null | undefined,
|
||||
activeOrgId: string | null | undefined,
|
||||
cachedIsPro?: boolean,
|
||||
cachedIsTeam?: boolean,
|
||||
cachedUsageData?: any,
|
||||
cachedSubscriptionData?: any,
|
||||
isParentLoading?: boolean
|
||||
) => {
|
||||
const [isPro, setIsPro] = useState<boolean>(cachedIsPro || false)
|
||||
const [isTeam, setIsTeam] = useState<boolean>(cachedIsTeam || false)
|
||||
const [usageData, setUsageData] = useState<{
|
||||
percentUsed: number;
|
||||
isWarning: boolean;
|
||||
isExceeded: boolean;
|
||||
currentUsage: number;
|
||||
limit: number;
|
||||
}>({
|
||||
percentUsed: number
|
||||
isWarning: boolean
|
||||
isExceeded: boolean
|
||||
currentUsage: number
|
||||
limit: number
|
||||
}>(cachedUsageData || {
|
||||
percentUsed: 0,
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
currentUsage: 0,
|
||||
limit: 0
|
||||
})
|
||||
const [subscriptionData, setSubscriptionData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [subscriptionData, setSubscriptionData] = useState<any>(cachedSubscriptionData || null)
|
||||
const [loading, setLoading] = useState<boolean>(isParentLoading !== undefined ? isParentLoading : true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const subscription = useSubscription()
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isParentLoading !== undefined ||
|
||||
(cachedIsPro !== undefined &&
|
||||
cachedIsTeam !== undefined &&
|
||||
cachedUsageData &&
|
||||
cachedSubscriptionData)
|
||||
) {
|
||||
if (cachedIsPro !== undefined) setIsPro(cachedIsPro)
|
||||
if (cachedIsTeam !== undefined) setIsTeam(cachedIsTeam)
|
||||
if (cachedUsageData) setUsageData(cachedUsageData)
|
||||
if (cachedSubscriptionData) setSubscriptionData(cachedSubscriptionData)
|
||||
if (isParentLoading !== undefined) setLoading(isParentLoading)
|
||||
return
|
||||
}
|
||||
|
||||
async function loadSubscriptionData() {
|
||||
if (!userId) return
|
||||
|
||||
@@ -92,15 +121,18 @@ const useSubscriptionData = (userId: string | null | undefined, activeOrgId: str
|
||||
logger.info('Checking organization subscription first', { orgId: activeOrgId })
|
||||
|
||||
// Get the organization's subscription
|
||||
const { data: orgSubscriptions, error: orgSubError } = await client.subscription.list({
|
||||
const result = await subscription.list({
|
||||
query: { referenceId: activeOrgId }
|
||||
})
|
||||
|
||||
const orgSubscriptions = result.data
|
||||
const orgSubError = 'error' in result ? result.error : null
|
||||
|
||||
if (orgSubError) {
|
||||
logger.error('Error fetching organization subscription details', orgSubError)
|
||||
} else {
|
||||
} else if (orgSubscriptions) {
|
||||
// Find active team subscription for the organization
|
||||
activeSubscription = orgSubscriptions?.find(
|
||||
activeSubscription = orgSubscriptions.find(
|
||||
sub => sub.status === 'active' && sub.plan === 'team'
|
||||
)
|
||||
|
||||
@@ -116,13 +148,16 @@ const useSubscriptionData = (userId: string | null | undefined, activeOrgId: str
|
||||
// If no org team subscription was found, check for personal subscription
|
||||
if (!activeSubscription) {
|
||||
// Fetch detailed subscription data for the user
|
||||
const { data: userSubscriptions, error: userSubError } = await client.subscription.list()
|
||||
const result = await subscription.list()
|
||||
|
||||
const userSubscriptions = result.data
|
||||
const userSubError = 'error' in result ? result.error : null
|
||||
|
||||
if (userSubError) {
|
||||
logger.error('Error fetching user subscription details', userSubError)
|
||||
} else {
|
||||
} else if (userSubscriptions) {
|
||||
// Find active subscription for the user
|
||||
activeSubscription = userSubscriptions?.find(
|
||||
activeSubscription = userSubscriptions.find(
|
||||
sub => sub.status === 'active'
|
||||
)
|
||||
}
|
||||
@@ -148,14 +183,22 @@ const useSubscriptionData = (userId: string | null | undefined, activeOrgId: str
|
||||
}
|
||||
|
||||
loadSubscriptionData()
|
||||
}, [userId, activeOrgId])
|
||||
}, [userId, activeOrgId, subscription, cachedIsPro, cachedIsTeam, cachedUsageData, cachedSubscriptionData, isParentLoading])
|
||||
|
||||
return { isPro, isTeam, usageData, subscriptionData, loading, error }
|
||||
}
|
||||
|
||||
export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
export function Subscription({
|
||||
onOpenChange,
|
||||
cachedIsPro,
|
||||
cachedIsTeam,
|
||||
cachedUsageData,
|
||||
cachedSubscriptionData,
|
||||
isLoading
|
||||
}: SubscriptionProps) {
|
||||
const { data: session } = useSession()
|
||||
const { data: activeOrg } = useActiveOrganization()
|
||||
const subscription = useSubscription()
|
||||
|
||||
const {
|
||||
isPro,
|
||||
@@ -164,7 +207,15 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
subscriptionData,
|
||||
loading,
|
||||
error: subscriptionError
|
||||
} = useSubscriptionData(session?.user?.id, activeOrg?.id)
|
||||
} = useSubscriptionData(
|
||||
session?.user?.id,
|
||||
activeOrg?.id,
|
||||
cachedIsPro,
|
||||
cachedIsTeam,
|
||||
cachedUsageData,
|
||||
cachedSubscriptionData,
|
||||
isLoading
|
||||
)
|
||||
|
||||
const [isCanceling, setIsCanceling] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -190,15 +241,15 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await client.subscription.upgrade({
|
||||
const result = await subscription.upgrade({
|
||||
plan: plan,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message || `There was an error upgrading to the ${plan} plan`)
|
||||
logger.error('Subscription upgrade error:', error)
|
||||
if ('error' in result && result.error) {
|
||||
setError(result.error.message || `There was an error upgrading to the ${plan} plan`)
|
||||
logger.error('Subscription upgrade error:', result.error)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Subscription upgrade exception:', error)
|
||||
@@ -218,13 +269,13 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await client.subscription.cancel({
|
||||
const result = await subscription.cancel({
|
||||
returnUrl: window.location.href,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message || 'There was an error canceling your subscription')
|
||||
logger.error('Subscription cancellation error:', error)
|
||||
if ('error' in result && result.error) {
|
||||
setError(result.error.message || 'There was an error canceling your subscription')
|
||||
logger.error('Subscription cancellation error:', result.error)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Subscription cancellation exception:', error)
|
||||
@@ -248,17 +299,18 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await client.subscription.upgrade({
|
||||
const result = await subscription.upgrade({
|
||||
plan: 'team',
|
||||
seats,
|
||||
successUrl: window.location.href,
|
||||
cancelUrl: window.location.href,
|
||||
seats: seats
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message || 'There was an error upgrading to the team plan')
|
||||
logger.error('Team subscription upgrade error:', error)
|
||||
if ('error' in result && result.error) {
|
||||
setError(result.error.message || 'There was an error upgrading to the team plan')
|
||||
logger.error('Team subscription upgrade error:', result.error)
|
||||
} else {
|
||||
// Close the dialog after successful upgrade
|
||||
setIsTeamDialogOpen(false)
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { client, useSubscription } from '@/lib/auth-client'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { Account } from './components/account/account'
|
||||
import { ApiKeys } from './components/api-keys/api-keys'
|
||||
import { Credentials } from './components/credentials/credentials'
|
||||
import { EnvironmentVariables } from './components/environment/environment'
|
||||
import { General } from './components/general/general'
|
||||
import { Privacy } from './components/privacy/privacy'
|
||||
import { Subscription } from './components/subscription/subscription'
|
||||
import { SettingsNavigation } from './components/settings-navigation/settings-navigation'
|
||||
import { TeamManagement } from './components/team-management/team-management'
|
||||
@@ -23,52 +25,92 @@ interface SettingsModalProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type SettingsSection = 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team'
|
||||
type SettingsSection = 'general' | 'environment' | 'account' | 'credentials' | 'apikeys' | 'subscription' | 'team' | 'privacy'
|
||||
|
||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
const [isPro, setIsPro] = useState(false)
|
||||
const [isTeam, setIsTeam] = useState(false)
|
||||
const [subscriptionData, setSubscriptionData] = useState<any>(null)
|
||||
const [usageData, setUsageData] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const loadSettings = useGeneralStore(state => state.loadSettings)
|
||||
const subscription = useMemo(() => useSubscription(), [])
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAllSettings() {
|
||||
if (!open) return
|
||||
|
||||
if (hasLoadedInitialData.current) return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await loadSettings()
|
||||
|
||||
const proStatusResponse = await fetch('/api/user/subscription')
|
||||
|
||||
if (proStatusResponse.ok) {
|
||||
const subData = await proStatusResponse.json()
|
||||
setIsPro(subData.isPro)
|
||||
setIsTeam(subData.isTeam)
|
||||
|
||||
if (!subData.isTeam && activeSection === 'team') {
|
||||
setActiveSection('general')
|
||||
}
|
||||
}
|
||||
|
||||
const usageResponse = await fetch('/api/user/usage')
|
||||
if (usageResponse.ok) {
|
||||
const usageData = await usageResponse.json()
|
||||
setUsageData(usageData)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await subscription.list()
|
||||
|
||||
if (result.data && result.data.length > 0) {
|
||||
const activeSubscription = result.data.find(
|
||||
sub => sub.status === 'active' && (sub.plan === 'team' || sub.plan === 'pro')
|
||||
)
|
||||
|
||||
if (activeSubscription) {
|
||||
setSubscriptionData(activeSubscription)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching subscription information', error)
|
||||
}
|
||||
|
||||
hasLoadedInitialData.current = true
|
||||
} catch (error) {
|
||||
logger.error('Error loading settings data:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
loadAllSettings()
|
||||
} else {
|
||||
hasLoadedInitialData.current = false
|
||||
}
|
||||
}, [open, loadSettings, subscription, activeSection])
|
||||
|
||||
// Listen for the custom event to open the settings modal with a specific tab
|
||||
useEffect(() => {
|
||||
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
|
||||
setActiveSection(event.detail.tab)
|
||||
onOpenChange(true)
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener('open-settings', handleOpenSettings as EventListener)
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
window.removeEventListener('open-settings', handleOpenSettings as EventListener)
|
||||
}
|
||||
}, [onOpenChange])
|
||||
|
||||
// Check if user is on team plan
|
||||
useEffect(() => {
|
||||
async function checkTeamPlan() {
|
||||
try {
|
||||
const response = await fetch('/api/user/subscription')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setIsTeam(data.isTeam)
|
||||
|
||||
if (!data.isTeam && activeSection === 'team') {
|
||||
setActiveSection('general')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error checking team plan:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
checkTeamPlan()
|
||||
}
|
||||
}, [open, activeSection])
|
||||
|
||||
// Check if subscriptions are enabled
|
||||
const isSubscriptionEnabled = !!client.subscription
|
||||
|
||||
return (
|
||||
@@ -116,9 +158,19 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<div className={cn('h-full', activeSection === 'apikeys' ? 'block' : 'hidden')}>
|
||||
<ApiKeys onOpenChange={onOpenChange} />
|
||||
</div>
|
||||
<div className={cn('h-full', activeSection === 'privacy' ? 'block' : 'hidden')}>
|
||||
<Privacy />
|
||||
</div>
|
||||
{isSubscriptionEnabled && (
|
||||
<div className={cn('h-full', activeSection === 'subscription' ? 'block' : 'hidden')}>
|
||||
<Subscription onOpenChange={onOpenChange} />
|
||||
<Subscription
|
||||
onOpenChange={onOpenChange}
|
||||
cachedIsPro={isPro}
|
||||
cachedIsTeam={isTeam}
|
||||
cachedUsageData={usageData}
|
||||
cachedSubscriptionData={subscriptionData}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isTeam && (
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "settings" ALTER COLUMN "general" SET DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD COLUMN "theme" text DEFAULT 'system' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD COLUMN "debug_mode" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD COLUMN "auto_connect" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD COLUMN "auto_fill_env_vars" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD COLUMN "telemetry_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD COLUMN "telemetry_notified_user" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -232,6 +232,13 @@
|
||||
"when": 1745973220228,
|
||||
"tag": "0032_rare_nico_minoru",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "7",
|
||||
"when": 1746037443324,
|
||||
"tag": "0033_solid_stellaris",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+19
-6
@@ -19,7 +19,7 @@ export const user = pgTable('user', {
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
stripeCustomerId: text('stripe_customer_id')
|
||||
});
|
||||
})
|
||||
|
||||
export const session = pgTable('session', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -126,7 +126,20 @@ export const settings = pgTable('settings', {
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' })
|
||||
.unique(), // One settings record per user
|
||||
general: json('general').notNull(),
|
||||
|
||||
// General settings
|
||||
theme: text('theme').notNull().default('system'),
|
||||
debugMode: boolean('debug_mode').notNull().default(false),
|
||||
autoConnect: boolean('auto_connect').notNull().default(true),
|
||||
autoFillEnvVars: boolean('auto_fill_env_vars').notNull().default(true),
|
||||
|
||||
// Privacy settings
|
||||
telemetryEnabled: boolean('telemetry_enabled').notNull().default(true),
|
||||
telemetryNotifiedUser: boolean('telemetry_notified_user').notNull().default(false),
|
||||
|
||||
// Keep general for future flexible settings
|
||||
general: json('general').notNull().default('{}'),
|
||||
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
@@ -238,7 +251,7 @@ export const subscription = pgTable("subscription", {
|
||||
seats: integer('seats'),
|
||||
trialStart: timestamp('trial_start'),
|
||||
trialEnd: timestamp('trial_end')
|
||||
});
|
||||
})
|
||||
|
||||
export const chat = pgTable('chat', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -281,7 +294,7 @@ export const organization = pgTable("organization", {
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
})
|
||||
|
||||
export const member = pgTable("member", {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -289,7 +302,7 @@ export const member = pgTable("member", {
|
||||
organizationId: text('organization_id').notNull().references(() => organization.id, { onDelete: 'cascade' }),
|
||||
role: text('role').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull()
|
||||
});
|
||||
})
|
||||
|
||||
export const invitation = pgTable("invitation", {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -300,4 +313,4 @@ export const invitation = pgTable("invitation", {
|
||||
status: text('status').notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull()
|
||||
});
|
||||
})
|
||||
+77
-1
@@ -26,6 +26,25 @@ import {
|
||||
|
||||
const logger = createLogger('Executor')
|
||||
|
||||
/**
|
||||
* Tracks telemetry events for workflow execution if telemetry is enabled
|
||||
*/
|
||||
function trackWorkflowTelemetry(eventName: string, data: Record<string, any>) {
|
||||
if (typeof window !== 'undefined' && window.__SIM_TRACK_EVENT) {
|
||||
// Add timestamp and sanitize the data to avoid circular references
|
||||
const safeData = {
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
// Track the event through the global telemetry function
|
||||
window.__SIM_TRACK_EVENT(eventName, {
|
||||
category: 'workflow',
|
||||
...safeData
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core execution engine that runs workflow blocks in topological order.
|
||||
*
|
||||
@@ -89,6 +108,14 @@ export class Executor {
|
||||
const startTime = new Date()
|
||||
let finalOutput: NormalizedBlockOutput = { response: {} }
|
||||
|
||||
// Track workflow execution start
|
||||
trackWorkflowTelemetry('workflow_execution_started', {
|
||||
workflowId,
|
||||
blockCount: this.workflow.blocks.length,
|
||||
connectionCount: this.workflow.connections.length,
|
||||
startTime: startTime.toISOString()
|
||||
})
|
||||
|
||||
this.validateWorkflow()
|
||||
|
||||
const context = this.createExecutionContext(workflowId, startTime)
|
||||
@@ -162,12 +189,23 @@ export class Executor {
|
||||
|
||||
const endTime = new Date()
|
||||
context.metadata.endTime = endTime.toISOString()
|
||||
const duration = endTime.getTime() - startTime.getTime()
|
||||
|
||||
trackWorkflowTelemetry('workflow_execution_completed', {
|
||||
workflowId,
|
||||
duration,
|
||||
blockCount: this.workflow.blocks.length,
|
||||
executedBlockCount: context.executedBlocks.size,
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
success: true
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: finalOutput,
|
||||
metadata: {
|
||||
duration: endTime.getTime() - startTime.getTime(),
|
||||
duration: duration,
|
||||
startTime: context.metadata.startTime!,
|
||||
endTime: context.metadata.endTime!,
|
||||
workflowConnections: this.workflow.connections.map((conn) => ({
|
||||
@@ -180,6 +218,15 @@ export class Executor {
|
||||
} catch (error: any) {
|
||||
logger.error('Workflow execution failed:', this.sanitizeError(error))
|
||||
|
||||
// Track workflow execution failure
|
||||
trackWorkflowTelemetry('workflow_execution_failed', {
|
||||
workflowId,
|
||||
duration: new Date().getTime() - startTime.getTime(),
|
||||
error: this.extractErrorMessage(error),
|
||||
executedBlockCount: context.executedBlocks.size,
|
||||
blockLogs: context.blockLogs.length
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput,
|
||||
@@ -732,6 +779,16 @@ export class Executor {
|
||||
// Resolve inputs (which will look up references to other blocks including starter)
|
||||
const inputs = this.resolver.resolveInputs(block, context)
|
||||
|
||||
// Track block execution start
|
||||
trackWorkflowTelemetry('block_execution_start', {
|
||||
workflowId: context.workflowId,
|
||||
blockId: block.id,
|
||||
blockType: block.metadata?.id || 'unknown',
|
||||
blockName: block.metadata?.name || 'Unnamed Block',
|
||||
inputSize: Object.keys(inputs).length,
|
||||
startTime: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Find the appropriate handler
|
||||
const handler = this.blockHandlers.find((h) => h.canHandle(block))
|
||||
if (!handler) {
|
||||
@@ -780,6 +837,15 @@ export class Executor {
|
||||
blockType: block.metadata?.id || 'unknown',
|
||||
})
|
||||
|
||||
trackWorkflowTelemetry('block_execution', {
|
||||
workflowId: context.workflowId,
|
||||
blockId: block.id,
|
||||
blockType: block.metadata?.id || 'unknown',
|
||||
blockName: block.metadata?.name || 'Unnamed Block',
|
||||
durationMs: Math.round(executionTime),
|
||||
success: true
|
||||
})
|
||||
|
||||
return output
|
||||
} catch (error: any) {
|
||||
// Remove this block from active blocks if there's an error
|
||||
@@ -859,6 +925,16 @@ export class Executor {
|
||||
}
|
||||
}
|
||||
|
||||
trackWorkflowTelemetry('block_execution_error', {
|
||||
workflowId: context.workflowId,
|
||||
blockId: block.id,
|
||||
blockType: block.metadata?.id || 'unknown',
|
||||
blockName: block.metadata?.name || 'Unnamed Block',
|
||||
durationMs: blockLog.durationMs,
|
||||
errorType: error.name || 'Error',
|
||||
errorMessage: this.extractErrorMessage(error)
|
||||
})
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Sim Studio Telemetry - Client-side Instrumentation
|
||||
*
|
||||
* This file initializes client-side telemetry when the app loads in the browser.
|
||||
* It respects the user's telemetry preferences stored in localStorage.
|
||||
*
|
||||
*/
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status'
|
||||
let telemetryEnabled = true
|
||||
|
||||
try {
|
||||
if (process.env.NEXT_TELEMETRY_DISABLED === '1') {
|
||||
telemetryEnabled = false
|
||||
} else {
|
||||
const storedPreference = localStorage.getItem(TELEMETRY_STATUS_KEY)
|
||||
if (storedPreference) {
|
||||
const status = JSON.parse(storedPreference)
|
||||
telemetryEnabled = status.enabled
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
telemetryEnabled = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe serialize function to ensure we don't include circular references or invalid data
|
||||
*/
|
||||
function safeSerialize(obj: any): any {
|
||||
if (obj === null || obj === undefined) return null
|
||||
if (typeof obj !== 'object') return obj
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => safeSerialize(item))
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
const value = obj[key]
|
||||
if (value === undefined || value === null || typeof value === 'function' || typeof value === 'symbol') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
result[key] = safeSerialize(value)
|
||||
} catch (e) {
|
||||
try {
|
||||
result[key] = String(value)
|
||||
} catch (e2) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
(window as any).__SIM_TELEMETRY_ENABLED = telemetryEnabled;
|
||||
(window as any).__SIM_TRACK_EVENT = (eventName: string, properties?: any) => {
|
||||
if (!telemetryEnabled) return
|
||||
|
||||
const safeProps = properties || {}
|
||||
|
||||
const payload = {
|
||||
category: 'feature_usage',
|
||||
action: eventName || 'unknown_event',
|
||||
timestamp: Date.now(),
|
||||
...safeSerialize(safeProps)
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending metrics fails
|
||||
})
|
||||
}
|
||||
|
||||
if (telemetryEnabled) {
|
||||
performance.mark('sim-studio-init')
|
||||
|
||||
let telemetryConfig
|
||||
try {
|
||||
telemetryConfig = (window as any).__SIM_STUDIO_TELEMETRY_CONFIG || {
|
||||
clientSide: { enabled: true },
|
||||
}
|
||||
} catch (e) {
|
||||
telemetryConfig = { clientSide: { enabled: true } }
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
performance.mark('sim-studio-loaded')
|
||||
performance.measure('page-load', 'sim-studio-init', 'sim-studio-loaded')
|
||||
|
||||
if (typeof PerformanceObserver !== 'undefined') {
|
||||
const lcpObserver = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries()
|
||||
|
||||
entries.forEach(entry => {
|
||||
const value = entry.entryType === 'largest-contentful-paint'
|
||||
? (entry as any).startTime
|
||||
: (entry as any).value || 0
|
||||
|
||||
// Ensure we have non-null values for all fields
|
||||
const metric = {
|
||||
name: entry.name || 'unknown',
|
||||
value: value || 0,
|
||||
entryType: entry.entryType || 'unknown'
|
||||
}
|
||||
|
||||
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
|
||||
const safePayload = {
|
||||
category: 'performance',
|
||||
action: 'web_vital',
|
||||
label: metric.name,
|
||||
value: metric.value,
|
||||
entryType: metric.entryType,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(safePayload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending metrics fails
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
lcpObserver.disconnect()
|
||||
})
|
||||
|
||||
const clsObserver = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries()
|
||||
let clsValue = 0
|
||||
|
||||
entries.forEach(entry => {
|
||||
clsValue += (entry as any).value || 0
|
||||
})
|
||||
|
||||
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
|
||||
const safePayload = {
|
||||
category: 'performance',
|
||||
action: 'web_vital',
|
||||
label: 'CLS',
|
||||
value: clsValue || 0,
|
||||
entryType: 'layout-shift',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(safePayload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending metrics fails
|
||||
})
|
||||
}
|
||||
|
||||
clsObserver.disconnect()
|
||||
})
|
||||
|
||||
const fidObserver = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries()
|
||||
|
||||
entries.forEach(entry => {
|
||||
const processingStart = (entry as any).processingStart || 0
|
||||
const startTime = (entry as any).startTime || 0
|
||||
|
||||
const metric = {
|
||||
name: entry.name || 'unknown',
|
||||
value: processingStart - startTime,
|
||||
entryType: entry.entryType || 'unknown'
|
||||
}
|
||||
|
||||
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
|
||||
const safePayload = {
|
||||
category: 'performance',
|
||||
action: 'web_vital',
|
||||
label: 'FID',
|
||||
value: metric.value,
|
||||
entryType: metric.entryType,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(safePayload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending metrics fails
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
fidObserver.disconnect()
|
||||
})
|
||||
|
||||
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true })
|
||||
clsObserver.observe({ type: 'layout-shift', buffered: true })
|
||||
fidObserver.observe({ type: 'first-input', buffered: true })
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
if (telemetryEnabled && telemetryConfig?.clientSide?.enabled) {
|
||||
const errorDetails = {
|
||||
message: event.error?.message || 'Unknown error',
|
||||
stack: event.error?.stack?.split('\n')[0] || '',
|
||||
url: window.location.pathname,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
const safePayload = {
|
||||
category: 'error',
|
||||
action: 'client_error',
|
||||
label: errorDetails.message,
|
||||
stack: errorDetails.stack,
|
||||
url: errorDetails.url,
|
||||
timestamp: errorDetails.timestamp
|
||||
}
|
||||
|
||||
fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(safePayload),
|
||||
}).catch(() => {
|
||||
// Silently fail if sending error fails
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Sim Studio Telemetry - Server-side Instrumentation
|
||||
*
|
||||
* This file can be customized in forked repositories:
|
||||
* - Set TELEMETRY_ENDPOINT env var to your collector
|
||||
* - Modify exporter configuration as needed
|
||||
*
|
||||
* Please maintain ethical telemetry practices if modified.
|
||||
*/
|
||||
|
||||
// This file enables OpenTelemetry instrumentation for Next.js
|
||||
// See: https://nextjs.org/docs/app/building-your-application/optimizing/open-telemetry
|
||||
// Set experimental.instrumentationHook = true in next.config.ts to enable this
|
||||
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const logger = createLogger('otel-instrumentation')
|
||||
|
||||
const DEFAULT_TELEMETRY_CONFIG = {
|
||||
endpoint: process.env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
|
||||
serviceName: 'sim-studio',
|
||||
serviceVersion: process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0',
|
||||
serverSide: { enabled: true },
|
||||
batchSettings: {
|
||||
maxQueueSize: 100,
|
||||
maxExportBatchSize: 10,
|
||||
scheduledDelayMillis: 5000,
|
||||
exportTimeoutMillis: 30000,
|
||||
},
|
||||
}
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
try {
|
||||
if (process.env.NEXT_TELEMETRY_DISABLED === '1') {
|
||||
logger.info('OpenTelemetry telemetry disabled via environment variable')
|
||||
return
|
||||
}
|
||||
|
||||
let telemetryConfig
|
||||
try {
|
||||
telemetryConfig = require('./telemetry.config.js')
|
||||
} catch (e) {
|
||||
telemetryConfig = DEFAULT_TELEMETRY_CONFIG
|
||||
}
|
||||
|
||||
if (telemetryConfig.serverSide?.enabled === false) {
|
||||
logger.info('Server-side OpenTelemetry instrumentation is disabled in config')
|
||||
return
|
||||
}
|
||||
|
||||
const { NodeSDK } = await import('@opentelemetry/sdk-node')
|
||||
const { resourceFromAttributes } = await import('@opentelemetry/resources')
|
||||
const { SemanticResourceAttributes } = await import('@opentelemetry/semantic-conventions')
|
||||
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node')
|
||||
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http')
|
||||
|
||||
const exporter = new OTLPTraceExporter({
|
||||
url: telemetryConfig.endpoint,
|
||||
})
|
||||
|
||||
const spanProcessor = new BatchSpanProcessor(exporter, {
|
||||
maxQueueSize: telemetryConfig.batchSettings?.maxQueueSize || DEFAULT_TELEMETRY_CONFIG.batchSettings.maxQueueSize,
|
||||
maxExportBatchSize: telemetryConfig.batchSettings?.maxExportBatchSize || DEFAULT_TELEMETRY_CONFIG.batchSettings.maxExportBatchSize,
|
||||
scheduledDelayMillis: telemetryConfig.batchSettings?.scheduledDelayMillis || DEFAULT_TELEMETRY_CONFIG.batchSettings.scheduledDelayMillis,
|
||||
exportTimeoutMillis: telemetryConfig.batchSettings?.exportTimeoutMillis || DEFAULT_TELEMETRY_CONFIG.batchSettings.exportTimeoutMillis,
|
||||
})
|
||||
|
||||
const configResource = resourceFromAttributes({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: telemetryConfig.serviceName,
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: telemetryConfig.serviceVersion,
|
||||
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV,
|
||||
})
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource: configResource,
|
||||
spanProcessors: [spanProcessor],
|
||||
})
|
||||
|
||||
sdk.start()
|
||||
|
||||
const shutdownHandler = async () => {
|
||||
await sdk.shutdown()
|
||||
.then(() => logger.info('OpenTelemetry SDK shut down successfully'))
|
||||
.catch(err => logger.error('Error shutting down OpenTelemetry SDK', err))
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdownHandler)
|
||||
process.on('SIGINT', shutdownHandler)
|
||||
|
||||
logger.info('OpenTelemetry instrumentation initialized for server-side telemetry')
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize OpenTelemetry instrumentation', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export interface LogEntry {
|
||||
metadata?: ToolCallMetadata | Record<string, any>
|
||||
}
|
||||
|
||||
// Define types for tool call tracking
|
||||
export interface ToolCallMetadata {
|
||||
toolCalls?: ToolCall[]
|
||||
cost?: {
|
||||
@@ -101,7 +100,6 @@ export async function persistExecutionLogs(
|
||||
// Check for agent block and tool calls
|
||||
let metadata: ToolCallMetadata | undefined = undefined
|
||||
|
||||
logger.debug('Block type:', log.blockType)
|
||||
// If this is an agent block
|
||||
if (log.blockType === 'agent' && log.output) {
|
||||
logger.debug('Processing agent block output for tool calls', {
|
||||
@@ -177,10 +175,6 @@ export async function persistExecutionLogs(
|
||||
|
||||
// Case 1: Direct toolCalls array
|
||||
if (Array.isArray(log.output.toolCalls)) {
|
||||
logger.debug('Found direct toolCalls array', {
|
||||
count: log.output.toolCalls.length,
|
||||
})
|
||||
|
||||
// Log raw timing data for debugging
|
||||
log.output.toolCalls.forEach((tc: any, idx: number) => {
|
||||
logger.debug(`Tool call ${idx} raw timing data:`, {
|
||||
@@ -202,14 +196,6 @@ export async function persistExecutionLogs(
|
||||
blockEndTime ? new Date(blockEndTime) : undefined
|
||||
)
|
||||
|
||||
// Log what we extracted
|
||||
logger.debug(`Tool call timing extracted:`, {
|
||||
name: toolCall.name,
|
||||
extracted_duration: duration,
|
||||
extracted_startTime: timing.startTime,
|
||||
extracted_endTime: timing.endTime,
|
||||
})
|
||||
|
||||
return {
|
||||
name: toolCall.name,
|
||||
duration: duration,
|
||||
@@ -224,10 +210,6 @@ export async function persistExecutionLogs(
|
||||
}
|
||||
// Case 2: toolCalls with a list array (as seen in the screenshot)
|
||||
else if (log.output.toolCalls && Array.isArray(log.output.toolCalls.list)) {
|
||||
logger.debug('Found toolCalls with list array', {
|
||||
count: log.output.toolCalls.list.length,
|
||||
})
|
||||
|
||||
// Log raw timing data for debugging
|
||||
log.output.toolCalls.list.forEach((tc: any, idx: number) => {
|
||||
logger.debug(`Tool call list ${idx} raw timing data:`, {
|
||||
@@ -300,14 +282,6 @@ export async function persistExecutionLogs(
|
||||
blockEndTime ? new Date(blockEndTime) : undefined
|
||||
)
|
||||
|
||||
// Log what we extracted
|
||||
logger.debug(`Response tool call timing extracted:`, {
|
||||
name: toolCall.name,
|
||||
extracted_duration: duration,
|
||||
extracted_startTime: timing.startTime,
|
||||
extracted_endTime: timing.endTime,
|
||||
})
|
||||
|
||||
return {
|
||||
name: toolCall.name,
|
||||
duration: duration,
|
||||
@@ -696,47 +670,23 @@ function getToolCallTimings(
|
||||
// Force a minimum duration of 1000ms if none exists
|
||||
if (!enhancedToolCall.duration || enhancedToolCall.duration === 0) {
|
||||
enhancedToolCall.duration = Math.max(1000, toolDuration)
|
||||
logger.debug(`Setting minimum duration for tool ${toolCall.name}`, {
|
||||
duration: enhancedToolCall.duration,
|
||||
})
|
||||
}
|
||||
|
||||
// Force reasonable startTime and endTime if missing
|
||||
if (!enhancedToolCall.startTime) {
|
||||
const startTimestamp = new Date(blockStart).getTime() + toolStartOffset
|
||||
enhancedToolCall.startTime = new Date(startTimestamp).toISOString()
|
||||
logger.debug(`Setting startTime for tool ${toolCall.name}`, {
|
||||
startTime: enhancedToolCall.startTime,
|
||||
})
|
||||
}
|
||||
|
||||
if (!enhancedToolCall.endTime) {
|
||||
const endTimestamp =
|
||||
new Date(enhancedToolCall.startTime).getTime() + enhancedToolCall.duration
|
||||
enhancedToolCall.endTime = new Date(endTimestamp).toISOString()
|
||||
logger.debug(`Setting endTime for tool ${toolCall.name}`, {
|
||||
endTime: enhancedToolCall.endTime,
|
||||
})
|
||||
}
|
||||
|
||||
return enhancedToolCall
|
||||
})
|
||||
|
||||
logger.debug('Finished estimating tool call timings', {
|
||||
originalTools: toolCalls.map((t) => ({
|
||||
name: t.name,
|
||||
hadDuration: !!t.duration,
|
||||
hadStartTime: !!t.startTime,
|
||||
hadEndTime: !!t.endTime,
|
||||
})),
|
||||
enhancedTools: result.map((t) => ({
|
||||
name: t.name,
|
||||
duration: t.duration,
|
||||
startTime: t.startTime,
|
||||
endTime: t.endTime,
|
||||
})),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -816,15 +766,6 @@ function extractTimingInfo(
|
||||
blockStartTime?: Date,
|
||||
blockEndTime?: Date
|
||||
): { startTime?: Date; endTime?: Date } {
|
||||
logger.debug('Extracting timing info from tool call', {
|
||||
tool: toolCall.name,
|
||||
hasStartTime: !!toolCall.startTime,
|
||||
hasEndTime: !!toolCall.endTime,
|
||||
hasTiming: !!toolCall.timing,
|
||||
blockStartRef: blockStartTime?.toISOString(),
|
||||
blockEndRef: blockEndTime?.toISOString(),
|
||||
})
|
||||
|
||||
let startTime: Date | undefined = undefined
|
||||
let endTime: Date | undefined = undefined
|
||||
|
||||
@@ -849,21 +790,13 @@ function extractTimingInfo(
|
||||
endTime = new Date(toolCall.completedAt)
|
||||
}
|
||||
|
||||
// If we have start time but no end time, calculate end time from duration
|
||||
if (startTime && !endTime) {
|
||||
const duration = extractDuration(toolCall)
|
||||
if (duration > 0) {
|
||||
endTime = new Date(startTime.getTime() + duration)
|
||||
logger.debug('Calculated end time from start time and duration', {
|
||||
tool: toolCall.name,
|
||||
startTime: startTime.toISOString(),
|
||||
duration,
|
||||
calculatedEndTime: endTime.toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Log the final timing information
|
||||
logger.debug('Final extracted timing info', {
|
||||
tool: toolCall.name,
|
||||
startTime: startTime?.toISOString(),
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Sim Studio Telemetry
|
||||
*
|
||||
* This file can be customized in forked repositories:
|
||||
* - Set TELEMETRY_ENDPOINT in telemetry.config.js to your collector
|
||||
* - Modify allowed event categories as needed
|
||||
* - Edit disclosure text to match your privacy policy
|
||||
*
|
||||
* Please maintain ethical telemetry practices if modified.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'
|
||||
|
||||
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR)
|
||||
|
||||
const logger = createLogger('telemetry')
|
||||
|
||||
export type TelemetryEvent = {
|
||||
name: string
|
||||
properties?: Record<string, any>
|
||||
}
|
||||
|
||||
export type TelemetryStatus = {
|
||||
enabled: boolean
|
||||
notifiedUser: boolean
|
||||
}
|
||||
|
||||
const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status'
|
||||
|
||||
let telemetryConfig = {
|
||||
endpoint: process.env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
|
||||
serviceName: 'sim-studio',
|
||||
serviceVersion: process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0',
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && (window as any).__SIM_STUDIO_TELEMETRY_CONFIG) {
|
||||
telemetryConfig = { ...telemetryConfig, ...(window as any).__SIM_STUDIO_TELEMETRY_CONFIG }
|
||||
}
|
||||
|
||||
let telemetryInitialized = false
|
||||
|
||||
/**
|
||||
* Gets the current telemetry status from localStorage
|
||||
*/
|
||||
export function getTelemetryStatus(): TelemetryStatus {
|
||||
if (typeof window === 'undefined') {
|
||||
return { enabled: true, notifiedUser: false }
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.env.NEXT_TELEMETRY_DISABLED === '1') {
|
||||
return { enabled: false, notifiedUser: true }
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(TELEMETRY_STATUS_KEY)
|
||||
return stored ? JSON.parse(stored) : { enabled: true, notifiedUser: false }
|
||||
} catch (error) {
|
||||
logger.error('Failed to get telemetry status from localStorage', error)
|
||||
return { enabled: true, notifiedUser: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the telemetry status in localStorage
|
||||
*/
|
||||
export function setTelemetryStatus(status: TelemetryStatus): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(TELEMETRY_STATUS_KEY, JSON.stringify(status))
|
||||
|
||||
if (status.enabled && !telemetryInitialized) {
|
||||
initializeClientTelemetry()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to set telemetry status in localStorage', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that the user has been notified about telemetry
|
||||
*/
|
||||
export function markUserNotified(): void {
|
||||
const status = getTelemetryStatus()
|
||||
setTelemetryStatus({ ...status, notifiedUser: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables telemetry
|
||||
*/
|
||||
export function disableTelemetry(): void {
|
||||
const currentStatus = getTelemetryStatus()
|
||||
if (currentStatus.enabled) {
|
||||
trackEvent('consent', 'opt_out')
|
||||
}
|
||||
|
||||
setTelemetryStatus({ enabled: false, notifiedUser: true })
|
||||
logger.info('Telemetry disabled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables telemetry
|
||||
*/
|
||||
export function enableTelemetry(): void {
|
||||
if (process.env.NEXT_TELEMETRY_DISABLED === '1') {
|
||||
logger.info('Telemetry disabled by environment variable, cannot enable')
|
||||
return
|
||||
}
|
||||
|
||||
const currentStatus = getTelemetryStatus()
|
||||
if (!currentStatus.enabled) {
|
||||
trackEvent('consent', 'opt_in')
|
||||
}
|
||||
|
||||
setTelemetryStatus({ enabled: true, notifiedUser: true })
|
||||
logger.info('Telemetry enabled')
|
||||
|
||||
if (!telemetryInitialized) {
|
||||
initializeClientTelemetry()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize client-side telemetry without OpenTelemetry SDK
|
||||
* This approach uses direct event tracking instead of the OTel SDK
|
||||
* to avoid TypeScript compatibility issues while still collecting useful data
|
||||
*/
|
||||
function initializeClientTelemetry(): void {
|
||||
if (typeof window === 'undefined' || telemetryInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const clientSideEnabled = (window as any).__SIM_STUDIO_TELEMETRY_CONFIG?.clientSide?.enabled !== false
|
||||
|
||||
if (!clientSideEnabled) {
|
||||
logger.info('Client-side telemetry disabled in configuration')
|
||||
return
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
trackEvent('page_view', window.location.pathname)
|
||||
|
||||
if (typeof window.history !== 'undefined') {
|
||||
const originalPushState = window.history.pushState
|
||||
window.history.pushState = function(...args) {
|
||||
const result = originalPushState.apply(this, args)
|
||||
trackEvent('page_view', window.location.pathname)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window.performance !== 'undefined') {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
if (performance.getEntriesByType) {
|
||||
const navigationTiming = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
|
||||
if (navigationTiming) {
|
||||
trackEvent('performance', 'page_load', window.location.pathname,
|
||||
navigationTiming.loadEventEnd - navigationTiming.startTime)
|
||||
}
|
||||
|
||||
const lcpEntries = performance.getEntriesByType('paint')
|
||||
.filter(entry => entry.name === 'largest-contentful-paint')
|
||||
if (lcpEntries.length > 0) {
|
||||
trackEvent('performance', 'largest_contentful_paint',
|
||||
window.location.pathname, lcpEntries[0].startTime)
|
||||
}
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
let target = e.target as HTMLElement | null
|
||||
let telemetryAction = null
|
||||
|
||||
while (target && !telemetryAction) {
|
||||
telemetryAction = target.getAttribute('data-telemetry')
|
||||
if (!telemetryAction) {
|
||||
target = target.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
if (telemetryAction) {
|
||||
trackEvent('feature_usage', telemetryAction)
|
||||
}
|
||||
}, { passive: true })
|
||||
|
||||
document.addEventListener('submit', (e) => {
|
||||
const form = e.target as HTMLFormElement
|
||||
const telemetryAction = form.getAttribute('data-telemetry')
|
||||
if (telemetryAction) {
|
||||
trackEvent('feature_usage', telemetryAction)
|
||||
}
|
||||
}, { passive: true })
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
const errorDetails = {
|
||||
message: event.error?.message || 'Unknown error',
|
||||
stack: event.error?.stack?.split('\n')[0] || '',
|
||||
url: window.location.pathname,
|
||||
}
|
||||
trackEvent('error', 'client_error', errorDetails.message)
|
||||
}, { passive: true })
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const errorDetails = {
|
||||
message: event.reason?.message || String(event.reason) || 'Unhandled promise rejection',
|
||||
url: window.location.pathname,
|
||||
}
|
||||
trackEvent('error', 'unhandled_rejection', errorDetails.message)
|
||||
}, { passive: true })
|
||||
|
||||
logger.info('Enhanced client-side telemetry initialized')
|
||||
telemetryInitialized = true
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize client-side telemetry', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a telemetry event
|
||||
*/
|
||||
export async function trackEvent(category: string, action: string, label?: string, value?: number): Promise<void> {
|
||||
const status = getTelemetryStatus()
|
||||
|
||||
if (!status.enabled) return
|
||||
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
await fetch('/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
category,
|
||||
action,
|
||||
label,
|
||||
value,
|
||||
timestamp: new Date().toISOString(),
|
||||
service: telemetryConfig.serviceName,
|
||||
version: telemetryConfig.serviceVersion,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||
url: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
if (category === 'consent') {
|
||||
logger.debug('Telemetry consent change', { action })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to track telemetry event', error)
|
||||
}
|
||||
}
|
||||
Generated
+1917
-2
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,17 @@
|
||||
"@browserbasehq/stagehand": "^2.0.0",
|
||||
"@cerebras/cerebras_cloud_sdk": "^1.23.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-collector": "^0.25.0",
|
||||
"@opentelemetry/exporter-jaeger": "^2.0.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
|
||||
"@opentelemetry/instrumentation-document-load": "^0.45.0",
|
||||
"@opentelemetry/instrumentation-user-interaction": "^0.45.0",
|
||||
"@opentelemetry/resources": "^2.0.0",
|
||||
"@opentelemetry/sdk-node": "^0.200.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.32.0",
|
||||
"@opentelemetry/web": "^0.24.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.5",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
|
||||
@@ -1,24 +1,136 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { GeneralStore } from './types'
|
||||
|
||||
const logger = createLogger('GeneralStore')
|
||||
|
||||
const CACHE_TIMEOUT = 5000
|
||||
const MAX_ERROR_RETRIES = 2
|
||||
|
||||
export const useGeneralStore = create<GeneralStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
isAutoConnectEnabled: true,
|
||||
isDebugModeEnabled: false,
|
||||
isAutoFillEnvVarsEnabled: true,
|
||||
theme: 'system',
|
||||
toggleAutoConnect: () =>
|
||||
set((state) => ({
|
||||
isAutoConnectEnabled: !state.isAutoConnectEnabled,
|
||||
})),
|
||||
toggleDebugMode: () => set((state) => ({ isDebugModeEnabled: !state.isDebugModeEnabled })),
|
||||
toggleAutoFillEnvVars: () =>
|
||||
set((state) => ({ isAutoFillEnvVarsEnabled: !state.isAutoFillEnvVarsEnabled })),
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => set({ theme }),
|
||||
}),
|
||||
(set, get) => {
|
||||
let lastLoadTime = 0
|
||||
let errorRetryCount = 0
|
||||
|
||||
return {
|
||||
isAutoConnectEnabled: true,
|
||||
isDebugModeEnabled: false,
|
||||
isAutoFillEnvVarsEnabled: true,
|
||||
theme: 'system',
|
||||
telemetryEnabled: true,
|
||||
telemetryNotifiedUser: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// Basic Actions
|
||||
toggleAutoConnect: () => {
|
||||
const newValue = !get().isAutoConnectEnabled
|
||||
set({ isAutoConnectEnabled: newValue })
|
||||
get().updateSetting('autoConnect', newValue)
|
||||
},
|
||||
|
||||
toggleDebugMode: () => {
|
||||
const newValue = !get().isDebugModeEnabled
|
||||
set({ isDebugModeEnabled: newValue })
|
||||
get().updateSetting('debugMode', newValue)
|
||||
},
|
||||
|
||||
toggleAutoFillEnvVars: () => {
|
||||
const newValue = !get().isAutoFillEnvVarsEnabled
|
||||
set({ isAutoFillEnvVarsEnabled: newValue })
|
||||
get().updateSetting('autoFillEnvVars', newValue)
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
set({ theme })
|
||||
get().updateSetting('theme', theme)
|
||||
},
|
||||
|
||||
setTelemetryEnabled: (enabled) => {
|
||||
set({ telemetryEnabled: enabled })
|
||||
get().updateSetting('telemetryEnabled', enabled)
|
||||
},
|
||||
|
||||
setTelemetryNotifiedUser: (notified) => {
|
||||
set({ telemetryNotifiedUser: notified })
|
||||
get().updateSetting('telemetryNotifiedUser', notified)
|
||||
},
|
||||
|
||||
// API Actions
|
||||
loadSettings: async (force = false) => {
|
||||
// Skip loading if settings were recently loaded (within 5 seconds)
|
||||
const now = Date.now()
|
||||
if (!force && now - lastLoadTime < CACHE_TIMEOUT) {
|
||||
logger.debug('Skipping settings load - recently loaded')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
const response = await fetch('/api/user/settings')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch settings')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
|
||||
set({
|
||||
isAutoConnectEnabled: data.autoConnect,
|
||||
isDebugModeEnabled: data.debugMode,
|
||||
isAutoFillEnvVarsEnabled: data.autoFillEnvVars,
|
||||
theme: data.theme,
|
||||
telemetryEnabled: data.telemetryEnabled,
|
||||
telemetryNotifiedUser: data.telemetryNotifiedUser,
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
lastLoadTime = now
|
||||
errorRetryCount = 0
|
||||
} catch (error) {
|
||||
logger.error('Error loading settings:', error)
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
isLoading: false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
updateSetting: async (key, value) => {
|
||||
try {
|
||||
const response = await fetch('/api/user/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update setting: ${key}`)
|
||||
}
|
||||
|
||||
set({ error: null })
|
||||
|
||||
lastLoadTime = Date.now()
|
||||
errorRetryCount = 0
|
||||
} catch (error) {
|
||||
logger.error(`Error updating setting ${key}:`, error)
|
||||
set({ error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
|
||||
if (errorRetryCount < MAX_ERROR_RETRIES) {
|
||||
errorRetryCount++
|
||||
logger.debug(`Retry attempt ${errorRetryCount} after error`)
|
||||
get().loadSettings(true)
|
||||
} else {
|
||||
logger.warn(`Max retries (${MAX_ERROR_RETRIES}) exceeded, skipping automatic loadSettings`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'general-settings',
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ export interface General {
|
||||
isDebugModeEnabled: boolean
|
||||
isAutoFillEnvVarsEnabled: boolean
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
telemetryEnabled: boolean
|
||||
telemetryNotifiedUser: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface GeneralActions {
|
||||
@@ -10,6 +14,19 @@ export interface GeneralActions {
|
||||
toggleDebugMode: () => void
|
||||
toggleAutoFillEnvVars: () => void
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => void
|
||||
setTelemetryEnabled: (enabled: boolean) => void
|
||||
setTelemetryNotifiedUser: (notified: boolean) => void
|
||||
loadSettings: (force?: boolean) => Promise<void>
|
||||
updateSetting: <K extends keyof UserSettings>(key: K, value: UserSettings[K]) => Promise<void>
|
||||
}
|
||||
|
||||
export type GeneralStore = General & GeneralActions
|
||||
|
||||
export type UserSettings = {
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
debugMode: boolean
|
||||
autoConnect: boolean
|
||||
autoFillEnvVars: boolean
|
||||
telemetryEnabled: boolean
|
||||
telemetryNotifiedUser: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Sim Studio Telemetry Configuration
|
||||
*
|
||||
* PRIVACY NOTICE:
|
||||
* - Telemetry is enabled by default to help us improve the product
|
||||
* - You can disable telemetry via:
|
||||
* 1. Settings UI > Privacy tab > Toggle off "Allow anonymous telemetry"
|
||||
* 2. Setting NEXT_TELEMETRY_DISABLED=1 environment variable
|
||||
*
|
||||
* This file allows you to configure telemetry collection for your
|
||||
* Sim Studio instance. If you've forked the repository, you can modify
|
||||
* this file to send telemetry to your own collector.
|
||||
*
|
||||
* We only collect anonymous usage data to improve the product:
|
||||
* - Feature usage statistics
|
||||
* - Error rates
|
||||
* - Performance metrics
|
||||
*
|
||||
* We NEVER collect:
|
||||
* - Personal information
|
||||
* - Workflow content or outputs
|
||||
* - API keys or tokens
|
||||
* - IP addresses or geolocation data
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Endpoint URL where telemetry data is sent
|
||||
* Change this if you want to send telemetry to your own collector
|
||||
*/
|
||||
endpoint: process.env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
|
||||
|
||||
/**
|
||||
* Service name used to identify this instance
|
||||
* You can change this
|
||||
*/
|
||||
serviceName: 'sim-studio',
|
||||
|
||||
/**
|
||||
* Version of the service, defaults to the app version
|
||||
*/
|
||||
serviceVersion: process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0',
|
||||
|
||||
/**
|
||||
* Batch settings for sending telemetry
|
||||
* - maxQueueSize: Max number of spans to buffer
|
||||
* - maxExportBatchSize: Max number of spans to send in a single batch
|
||||
* - scheduledDelayMillis: Delay between batches (ms)
|
||||
* - exportTimeoutMillis: Timeout for exporting data (ms)
|
||||
*/
|
||||
batchSettings: {
|
||||
maxQueueSize: 100,
|
||||
maxExportBatchSize: 10,
|
||||
scheduledDelayMillis: 5000,
|
||||
exportTimeoutMillis: 30000,
|
||||
},
|
||||
|
||||
/**
|
||||
* Categories of events that can be collected
|
||||
* This is used for validation when events are sent
|
||||
*/
|
||||
allowedCategories: [
|
||||
'page_view',
|
||||
'feature_usage',
|
||||
'performance',
|
||||
'error',
|
||||
'workflow',
|
||||
'consent',
|
||||
],
|
||||
|
||||
/**
|
||||
* Client-side instrumentation settings
|
||||
* Set enabled: false to disable client-side telemetry entirely
|
||||
*/
|
||||
clientSide: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Server-side instrumentation settings
|
||||
* Set enabled: false to disable server-side telemetry entirely
|
||||
*/
|
||||
serverSide: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user