Events Module

Analytics-agnostic event tracking, providers, webhooks, and anti-spam protection.

Overview

Foundry's event system is designed so you can swap analytics providers without changing a single line of event code.

User Action → useEvents().trackEvent() → nuxtApp.callHook('events:track') → Providers
                                                                              ├── Console (logs)
                                                                              ├── Umami (analytics)
                                                                              └── Webhook (server delivery)
  1. A component calls trackEvent() with an event payload
  2. The payload is enriched with user ID and timestamp
  3. The events:track hook fires
  4. Each registered provider listens on that hook and handles the event independently

This means adding or removing an analytics provider is a config change — no event code needs to change.

Configuration

nuxt.config.ts
export default defineNuxtConfig({
  events: {
    providers: ['console', 'umami', 'webhook'],
    webhook: {
      enabled: true,
      platforms: ['discord'],
    },
    debug: false,
  },
})
OptionTypeDefaultDescription
providersstring[]['console']Active providers: 'console', 'umami', 'webhook'
webhook.enabledbooleanfalseRegister the /api/v1/webhook server handler
webhook.platformsstring[][]Supported platforms: 'discord', 'slack', 'telegram'
debugbooleanfalseEnable debug logging

Tracking Events

useEvents()

The primary API for firing events:

const { trackEvent } = useEvents()

await trackEvent({
  type: 'offer_click',
  location: 'hero',
  target: 'payment_external',
})

trackEvent accepts a partial EventPayload and enriches it with:

  • id — Auto-generated UUID if not provided
  • timestamp — Current time
  • data.userId — Anonymous user ID from useUserIdentity()

Event Types

TypeCategoryFired By
form_submittedFormConvertForm on successful submission
form_errorFormConvertForm on submission failure
offer_clickConversionConvertExternal, ConvertInternal, ConvertSocial, ConvertRss
section_viewEngagementSectionWrapper when a section enters the viewport

Payload Shapes

Base Fields (all events)

interface BaseEventPayload {
  id: string           // UUID
  timestamp: number    // Date.now()
  action?: string      // Optional action descriptor
  location?: string    // Where on the page (e.g., 'hero', 'footer')
  target?: string      // What was interacted with (e.g., 'payment_external')
}

form_submitted

interface FormSubmittedPayload extends BaseEventPayload {
  type: 'form_submitted'
  target: string
  data: {
    formData: Record<string, unknown>   // Validated form fields
    antiSpam?: {
      honeypot: string
      timeOnForm: number
      jsToken: string
    }
  }
}

form_error

interface FormErrorPayload extends BaseEventPayload {
  type: 'form_error'
  target: string
  error: unknown
  data?: Record<string, unknown>
}

offer_click / section_view

interface GenericEventPayload extends BaseEventPayload {
  type: TrackedEvents     // 'offer_click' | 'section_view' | etc.
  data?: Record<string, unknown>
}

Target Naming Convention

Targets follow the {type}_{direction} pattern:

TargetMeaning
payment_externalClick to external payment link
booking_externalClick to external booking link
mentorship_internalClick to internal mentorship offer page
social_externalClick to social media profile

Anonymous User Identity

useUserIdentity() manages a persistent anonymous user ID:

const { getUserId } = useUserIdentity()
const userId = getUserId()  // 'user_a1b2c3d4-...'
  • On first visit, generates user_{uuid} and stores in localStorage
  • Returns the same ID on subsequent visits
  • Returns empty string on the server (SSR-safe)
  • No personal data is collected — this is purely for event correlation

Auto-Tracked Events

SectionWrapper automatically fires section_view when a section enters the viewport (threshold: 20% visible). It fires once per section per page load, then stops observing.

All other events are fired by convert components on user interaction.

Providers

Each provider is a Nuxt client-side plugin that listens on the events:track hook. Providers run independently — if one fails, others still process the event.

Console Provider

Logs events to the browser console. Useful for development.

Config: Add 'console' to providers array.

Behavior: Calls console.log() with the full event payload. No external dependencies.

Umami Provider

Sends events to Umami, a privacy-first analytics platform.

Config: Add 'umami' to providers array + configure Umami script.

Environment variables:

VariableDescription
NUXT_PUBLIC_SCRIPTS_UMAMI_ANALYTICS_WEBSITE_IDYour Umami website ID
NUXT_PUBLIC_SCRIPTS_UMAMI_ANALYTICS_SCRIPT_INPUT_SRCUmami script URL

Behavior:

  • Uses Nuxt Scripts to load the Umami tracker
  • Forwards event ID, location, action, target, and timestamp
  • Mocked automatically in development (via scripts.registry.umamiAnalytics: 'mock')
  • Active in production (via scripts.registry.umamiAnalytics: true)

Setup: See the Umami documentation to create an account and get your website ID.

Webhook Provider

Sends form_submitted events to the server webhook handler for delivery to Slack, Discord, or Telegram.

Config: Add 'webhook' to providers array + enable the server handler:

nuxt.config.ts
events: {
  providers: ['webhook'],
  webhook: {
    enabled: true,
    platforms: ['discord'],
  },
}

Behavior:

  • Filters events — only processes form_submitted type
  • POSTs { formData, antiSpam } to /api/v1/webhook
  • Sets payload.response on success
  • Other event types are ignored by this provider

Using Multiple Providers

Providers compose naturally. A typical production setup:

nuxt.config.ts
events: {
  providers: ['umami', 'webhook'],  // Console removed for production
  webhook: {
    enabled: true,
    platforms: ['discord', 'telegram'],
  },
}

When a user submits a form:

  1. Umami records the event for analytics dashboards
  2. Webhook delivers the form data to Discord and Telegram

Both happen independently from the same trackEvent() call.

Provider Registration (Custom)

Providers are Nuxt client-side plugins that register themselves on the events:track hook:

// Simplified provider structure
export default defineNuxtPlugin({
  name: 'events-provider-example',
  dependsOn: ['events-core'],
  setup(nuxtApp) {
    nuxtApp.hook('events:track', (payload) => {
      // Handle the event (send to analytics, log, etc.)
    })
  },
})

The events-core plugin must load first (enforced via dependsOn), followed by any number of providers.

Webhooks

When webhook.enabled is true, Foundry registers a server handler at POST /api/v1/webhook. This receives form submissions from the webhook provider and delivers them to your configured destinations.

Environment Variables

VariableRequiredDescription
NUXT_WEBHOOK_URLYesWebhook destination URL(s). Comma-separated for multiple.
NUXT_TELEGRAM_CHAT_IDTelegram onlyYour Telegram chat ID

Platform Auto-Detection

The handler detects the platform from the webhook URL:

URL ContainsPlatformFormat
discord.comDiscordEmbeds with color-coded risk score
hooks.slack.comSlackBlock Kit with mrkdwn sections
api.telegram.orgTelegramPlain text with emoji indicators
Anything elseUnknownJSON POST with raw data

Multiple Destinations

Send to multiple platforms by comma-separating URLs:

NUXT_WEBHOOK_URL=https://discord.com/api/webhooks/...,https://api.telegram.org/bot.../sendMessage
NUXT_TELEGRAM_CHAT_ID=your-chat-id

All webhooks fire in parallel via Promise.allSettled. The request succeeds if at least one webhook delivery succeeds.

Message Formatting

Discord: Rich embed with color-coded spam risk score (green/yellow/red), email shown prominently, form fields as embed fields, timestamp.

Slack: Block Kit message with header section, fields section with form data, context section with timestamp and risk score.

Telegram: Plain text with emoji indicators for each field, risk score indicator, chat ID from NUXT_TELEGRAM_CHAT_ID.

Server-Side Flow

POST /api/v1/webhook
  ↓
1. Validate body (Zod: { formData, antiSpam? })
  ↓
2. Anti-spam pipeline:
   a. Honeypot check → hard reject (silent 200)
   b. Rate limiter → 429 if exceeded
   c. JS token validation → scored
  ↓
3. Parse webhook URLs from NUXT_WEBHOOK_URL
  ↓
4. Auto-detect platform for each URL
  ↓
5. Format message per platform
  ↓
6. Send all webhooks in parallel
  ↓
7. Return success if ≥1 delivery succeeded

Testing Webhooks

In development, use the DevEvents component to fire test events without filling out forms. You can also use a tool like webhook.site as a temporary destination:

NUXT_WEBHOOK_URL=https://webhook.site/your-unique-url

Structured Logging

The webhook handler uses evlog for structured logging throughout the request lifecycle. Every step (validation, anti-spam, delivery) is logged with context, making debugging straightforward.

Anti-Spam

Foundry uses three complementary techniques to stop spam without CAPTCHAs.

Honeypot Field

A hidden form field that is invisible to humans but visible to bots. If a bot fills it, the submission is silently rejected — the bot receives a success response and is redirected, but no webhook is sent.

Client side: Hidden <input> in ConvertForm, tracked by useFormCaptureServer side: Hard reject in webhook handler (returns 200 to avoid bot retry)

Time-on-Form

Tracks how long the form was visible before submission. Legitimate users take at least a few seconds. Bots submit instantly.

Threshold: Submissions under 2000ms are flagged (+40 to spam score)

JS Token

A crypto.randomUUID() generated when the form mounts. Bots without JavaScript execution cannot produce a valid UUID v4.

Check: Missing or invalid UUID adds +30 to spam score

Spam Scoring

The server combines all signals into a single score (0–100):

SignalScoreDescription
Honeypot filled100Instant bot detection (hard reject)
Fast submission+40Under 2000ms on form
No JS / invalid token+30Missing or invalid UUID

Scores above 0 are included in the webhook message so you can see the risk level. The spam score is color-coded in Discord (green < 30, yellow 30–69, red 70+).

Rate Limiting

IP-based rate limiting prevents abuse:

SettingDefaultDescription
Max submissions5Per time window per IP
Time window15 minutesSliding window

When rate limited, the server returns a 429 error.

The rate limiter runs in-memory with a probabilistic cleanup (1% chance per request) to prevent unbounded memory growth.

Anti-Spam Configuration

Rate limiting constants are defined in modules/events/server/utils/anti-spam.ts:

export const RATE_LIMIT_CONFIG = {
  max: 5,
  windowMin: 15,
  minFormTime: 2000,
} as const

DevEvents Component

In development mode, a DevEvents component is available for testing events without a real form submission. It provides:

  • Event type selector grouped by category
  • Auto-generated mock data for each event type
  • Event chain status tracking (pending, success, error)
  • Full payload inspection

This component is dev-only and throws an error if used in production.