Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/sim/app/_shell/providers/posthog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
capture_performance: false,
capture_dead_clicks: false,
enable_heatmaps: false,
/**
* PostHog's own error tracking, wired to `window.onerror` and
* `unhandledrejection`. This is the app-wide net: React error
* boundaries only see errors thrown inside the tree they wrap, and
* a failed chunk load, a rejected promise, or anything thrown from
* an event handler or socket callback reaches none of them.
*
* `capture_console_errors` stays off. It is not error reporting —
* it captures every `console.error`, which here means React's
* hydration and dev warnings (the ones `HydrationErrorHandler`
* already filters out as noise) drowning the real exceptions.
*/
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: false,
},
disable_session_recording: true,
session_recording: {
maskAllInputs: false,
Expand Down
39 changes: 37 additions & 2 deletions apps/sim/app/_shell/public-env-script.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
* @vitest-environment node
*/
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { PublicEnvScript } from '@/app/_shell/public-env-script'
import { describe, expect, it, vi } from 'vitest'
import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'
import { PublicEnvScript, publicEnvHtmlAttributes } from '@/app/_shell/public-env-script'

vi.unmock('@/lib/core/config/env')

/**
* Guards the one property that matters: the emitted tag assigns `window.__ENV`
Expand Down Expand Up @@ -32,3 +35,35 @@ describe('PublicEnvScript', () => {
expect(keys.every((key) => /^NEXT_PUBLIC_/i.test(key))).toBe(true)
})
})

/**
* The script above is rendered from the component tree, so it lands at the end
* of `<head>` - after the bootstrap chunks that can already be executing. These
* attributes go on `<html>`, the document's first tag, which is what makes the
* same values readable by code that runs in that gap.
*/
describe('publicEnvHtmlAttributes', () => {
it('carries the public env under the attribute getEnv reads', () => {
const attributes = publicEnvHtmlAttributes()

expect(Object.keys(attributes)).toEqual([PUBLIC_ENV_ATTRIBUTE])
expect(() => JSON.parse(attributes[PUBLIC_ENV_ATTRIBUTE])).not.toThrow()
})

it('exposes only NEXT_PUBLIC_ variables', () => {
const values = JSON.parse(publicEnvHtmlAttributes()[PUBLIC_ENV_ATTRIBUTE])

expect(Object.keys(values).every((key) => /^NEXT_PUBLIC_/i.test(key))).toBe(true)
})

/**
* Two transports for one snapshot only stays safe while they agree; a reader
* that resolved different values depending on which one it happened to hit
* would be worse than the race this replaces.
*/
it('carries exactly what the script assigns', () => {
const values = JSON.parse(publicEnvHtmlAttributes()[PUBLIC_ENV_ATTRIBUTE])

expect(values).toEqual(PublicEnvScript().props.env)
})
})
57 changes: 43 additions & 14 deletions apps/sim/app/_shell/public-env-script.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,48 @@
import { EnvScript } from 'next-runtime-env'
import { PUBLIC_ENV_ATTRIBUTE } from '@/lib/core/config/env'

/**
* Every `NEXT_PUBLIC_*` value currently in `process.env`. Filter matches
* `next-runtime-env`'s own `getPublicEnv()` exactly.
*/
function readPublicEnv(): Record<string, string | undefined> {
return Object.fromEntries(
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
)
}

/**
* `NEXT_PUBLIC_*` values, captured once when this module is first loaded - i.e.
* at server start on the hosted deployment, where a build's env never changes
* between requests. Filter matches `next-runtime-env`'s own `getPublicEnv()`
* exactly.
* between requests (`bootstrap.ts` awaits the runtime secret before importing
* the server, so `process.env` is complete before any module evaluates).
*
* These are deliberately NOT the values Next inlines into the client bundle:
* the image is built with placeholder `NEXT_PUBLIC_*` values and the real ones
* are supplied to the container at start, so `window.__ENV` is the only source
* of truth in the browser.
* are supplied to the container at start, so the browser has no compiled-in
* copy to fall back on.
*/
const HOSTED_PUBLIC_ENV = readPublicEnv()

/**
* Props to spread onto the `<html>` element so the public env is readable by any
* client code that can run at all.
*
* The script below is rendered from the component tree and therefore lands at
* the end of `<head>`, well after the `<script async>` bootstrap tags React
* emits in the preamble — see {@link PUBLIC_ENV_ATTRIBUTE} for the full ordering
* argument and why that gap is reachable. `<html>` is the document's first tag,
* so its attributes are parsed before any script exists to read them.
*
* Read fresh rather than from {@link HOSTED_PUBLIC_ENV} so the one helper serves
* both deployment modes: self-hosted images re-inject env per deploy without a
* rebuild, and `next-runtime-env`'s script reads `process.env` per request for
* exactly that reason. On hosted the two reads are the same values, because
* nothing mutates `process.env` after boot.
*/
const HOSTED_PUBLIC_ENV = Object.fromEntries(
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
)
export function publicEnvHtmlAttributes(): Record<string, string> {
return { [PUBLIC_ENV_ATTRIBUTE]: JSON.stringify(readPublicEnv()) }
}

/**
* Static equivalent of `next-runtime-env`'s `<PublicEnvScript>` for the hosted
Expand All @@ -35,13 +64,13 @@ const HOSTED_PUBLIC_ENV = Object.fromEntries(
* `window.__ENV` stays undefined for the entire lifetime of the document -
* every `getEnv` read empty, until a reload happens to win the race.
*
* A plain `<script>` assigns unconditionally when the parser reaches it. When
* it is reached before the bootstrap chunk runs it lands strictly earlier than
* the queue drain would have; when it is not, the value still arrives a few
* milliseconds late instead of never. There is no supported way to place an
* inline script ahead of the framework's own bootstrap tags - React emits those
* in the preamble, before any content from the component tree - so the goal is
* to make losing that race harmless rather than to try to win it.
* A plain `<script>` assigns unconditionally when the parser reaches it, so a
* lost race costs milliseconds instead of the session. It does not make the
* assignment win the race, though: draining that queue was also the only thing
* sequencing the assignment ahead of `hydrate()`, and with the queue empty
* `appBootstrap` hydrates synchronously. {@link publicEnvHtmlAttributes} is what
* closes the remaining window - this tag stays because `window.__ENV` is the
* documented global, and it is what `getEnv` reads first.
*/
export function PublicEnvScript() {
return <EnvScript env={HOSTED_PUBLIC_ENV} disableNextScript />
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { QueryProvider } from '@/app/_shell/providers/query-provider'
import { SessionProvider } from '@/app/_shell/providers/session-provider'
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
import { TooltipProvider } from '@/app/_shell/providers/tooltip-provider'
import { PublicEnvScript } from '@/app/_shell/public-env-script'
import { PublicEnvScript, publicEnvHtmlAttributes } from '@/app/_shell/public-env-script'
import { season } from '@/app/_styles/fonts/season/season'

export const viewport: Viewport = {
Expand All @@ -40,7 +40,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
const themeCSS = generateThemeCSS()

return (
<html lang='en' suppressHydrationWarning>
<html lang='en' suppressHydrationWarning {...publicEnvHtmlAttributes()}>
<head>
{isReactScanEnabled && (
<Script
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
'use client'

import { Component, type ReactNode, useEffect } from 'react'
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@sim/emcn'
import { RefreshCw } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { truncate } from '@sim/utils/string'
import { ReactFlowProvider } from 'reactflow'
import { captureClientEvent } from '@/lib/posthog/client'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { readCollapsedCookie } from '@/stores/sidebar/store'

const logger = createLogger('ErrorBoundary')

/** Keeps a runaway stack out of the event payload without losing the top frames. */
const MAX_REPORTED_COMPONENT_STACK = 2000

/**
* Shared Error UI Component
*/
Expand Down Expand Up @@ -90,6 +95,33 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return { hasError: true, error }
}

/**
* Reports what was caught. This boundary latches for the life of the document
* and its fallback names nothing, so without this the only trace of a canvas
* crash is React's own console output on whichever machine happened to hit it
* — leaving an intermittent failure with no evidence to diagnose from.
* `error.name` is carried separately from the message because it is what
* separates the failure classes from each other.
*/
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const componentStack = errorInfo.componentStack ?? undefined

logger.error('Workflow canvas crashed', {
name: error.name,
message: error.message,
stack: error.stack,
componentStack,
})

captureClientEvent('workflow_canvas_crashed', {
error_name: error.name,
error_message: error.message,
component_stack: componentStack
? truncate(componentStack, MAX_REPORTED_COMPONENT_STACK)
: undefined,
})
}

public render() {
if (this.state.hasError) {
return this.props.fallback || <ErrorUI />
Expand All @@ -98,20 +130,3 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return this.props.children
}
}

/**
* Next.js Error Page Component
* Renders when a workflow-specific error occurs
*/
interface NextErrorProps {
error: Error & { digest?: string }
reset: () => void
}

export function NextError({ error, reset }: NextErrorProps) {
useEffect(() => {
logger.error('Workflow error:', { error })
}, [error])

return <ErrorUI onReset={reset} />
}
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,12 @@ const SubBlockRow = memo(function SubBlockRow({
if (!subBlock?.id?.startsWith('webhookUrlDisplay') || !blockId) {
return null
}
/* Deliberately unguarded. `getBaseUrl` throws when no application base URL is
configured, and that is the right outcome here: this value gets copied into
a third-party provider, so a guessed origin would hand the user a URL that
provider accepts and then never delivers to, and a blank row explains
nothing. The error boundary reports what it caught, so the throw names its
own cause. */
const baseUrl = getBaseUrl()
const triggerPath = allSubBlockValues?.triggerPath?.value as string | undefined
return triggerPath
Expand Down
65 changes: 65 additions & 0 deletions apps/sim/app/workspace/providers/socket-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ import { backoffWithJitter } from '@sim/utils/retry'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import type { Socket } from 'socket.io-client'
import { getEnv } from '@/lib/core/config/env'
import { getSocketUrl } from '@/lib/core/utils/urls'
import { captureClientEvent } from '@/lib/posthog/client'
import {
type SocketJoinCommand,
SocketJoinController,
Expand All @@ -54,6 +56,14 @@ const logger = createLogger('SocketContext')

const TAB_SESSION_ID_KEY = 'sim_tab_session_id'

/**
* Consecutive connect failures before the realtime connection is reported as
* failing. Three attempts at the 1s base delay lands around the same few seconds
* as the "Reconnecting…" toast, so the event marks a real outage rather than the
* sub-second transport hiccups that recover on the first retry.
*/
const CONNECT_FAILURES_BEFORE_REPORT = 3

/** Bounded auto-retry budget for auth-class connect failures before going terminal. */
const MAX_AUTH_RETRY_ATTEMPTS = 5
const AUTH_RETRY_BASE_MS = 1000
Expand Down Expand Up @@ -174,6 +184,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const authRetryAttemptsRef = useRef(0)
const authRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sessionRejectedRef = useRef(false)
const connectFailureCountRef = useRef(0)
const connectFailureReportedRef = useRef(false)
const queryClient = useQueryClient()

const params = useParams()
Expand Down Expand Up @@ -375,6 +387,15 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
try {
const { io } = await import('socket.io-client')
const socketUrl = getSocketUrl()
/* Origin only: enough to tell a misresolved host from a down service,
without putting a path or query into an analytics payload. `new URL`
rather than `URL.parse`, which is unavailable on Safari below 18. */
let socketOrigin = 'unparseable'
try {
socketOrigin = new URL(socketUrl).origin
} catch {
/* Reported as-is; an unparseable socket URL is itself the finding. */
}

logger.info('Attempting to connect to Socket.IO server', {
url: socketUrl,
Expand Down Expand Up @@ -409,11 +430,52 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
},
})

/**
* Reports a realtime connection that is not coming back.
*
* A socket that never connects raises no exception anywhere — every
* failure path here is handled, so error tracking sees nothing and the
* only user-visible trace is a "Reconnecting…" toast. Socket.IO then
* retries the same URL forever, so the failure is both permanent and
* silent. This is the one signal that distinguishes "the realtime
* service is down" from "this client resolved the wrong URL", which is
* why the origin is reported alongside the reason.
*
* Fires at most once per socket instance, at the point the toast
* appears, rather than once per retry.
*
* Called from `connect_error` and nowhere else. That is the only handler
* that sees exactly one event per attempt: a failed *reconnect* also
* emits the manager's `reconnect_error` (`manager.reconnect()` calls
* `open()`, whose error path emits `error` — which the socket re-emits as
* `connect_error` — and then emits `reconnect_error` itself), so counting
* in both would advance twice per try and trip the threshold early.
*/
const reportPersistentConnectFailure = (reason: string) => {
connectFailureCountRef.current += 1
if (
connectFailureReportedRef.current ||
connectFailureCountRef.current < CONNECT_FAILURES_BEFORE_REPORT
) {
return
}
connectFailureReportedRef.current = true

captureClientEvent('realtime_connection_failing', {
socket_origin: socketOrigin,
expected_socket_origin_configured: Boolean(getEnv('NEXT_PUBLIC_SOCKET_URL')?.trim()),
attempts: connectFailureCountRef.current,
reason,
})
}
Comment thread
icecrasher321 marked this conversation as resolved.

socketInstance.on('connect', () => {
setIsConnected(true)
setIsConnecting(false)
setIsReconnecting(false)
authRetryAttemptsRef.current = 0
connectFailureCountRef.current = 0
connectFailureReportedRef.current = false
clearAuthRetryTimeout()
setCurrentSocketId(socketInstance.id ?? null)
logger.info('Socket connected successfully', {
Expand Down Expand Up @@ -451,6 +513,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
message: error.message,
})
setIsReconnecting(true)
reportPersistentConnectFailure(error.message)
Comment thread
icecrasher321 marked this conversation as resolved.
return
}

Expand Down Expand Up @@ -518,6 +581,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
logger.info('Socket reconnection attempt', { attemptNumber })
})

/* Deliberately does not count toward the outage report — the socket's
own `connect_error` already fired for this same attempt. */
socketInstance.io.on('reconnect_error', (error: Error) => {
logger.warn('Socket reconnection attempt failed, will retry', {
message: error.message,
Expand Down
Loading
Loading