Skip to main content

Integration Plugin Architecture

Learn how to create new integration plugins for Dashtray.

Overview

Dashtray uses a plugin architecture for integrations, making it easy to add new services. Each integration is a self-contained module that implements a standard interface.

Plugin Structure

src/lib/server/integrations/
├── BaseIntegration.ts          # Abstract base class
├── types.ts                    # TypeScript interfaces
├── registry.ts                 # Plugin registry
├── index.ts                    # Public exports
└── plugins/
    ├── stripe.ts               # Stripe integration
    ├── github.ts               # GitHub integration
    ├── vercel.ts               # Vercel integration
    ├── google-analytics.ts     # Google Analytics integration
    └── plausible.ts            # Plausible integration

Base Integration Class

All integrations extend BaseIntegration:
// src/lib/server/integrations/BaseIntegration.ts
import type {
  IntegrationPlugin,
  IntegrationCredentials,
  IntegrationMetric,
  TestConnectionResult,
  FetchMetricsOptions,
  FetchMetricsResult
} from './types';

export abstract class BaseIntegration implements IntegrationPlugin {
  abstract id: string;
  abstract name: string;
  abstract category: 'payment' | 'development' | 'analytics' | 'marketing' | 'communication';
  abstract icon: string;
  abstract authType: 'api_key' | 'oauth';
  abstract credentialsSchema: Record<string, CredentialField>;
  abstract metrics: IntegrationMetric[];

  /**
   * Test if credentials are valid
   */
  abstract testConnection(credentials: IntegrationCredentials): Promise<TestConnectionResult>;

  /**
   * Fetch metrics from the integration
   */
  abstract fetchMetrics(
    credentials: IntegrationCredentials,
    options: FetchMetricsOptions
  ): Promise<FetchMetricsResult>;

  /**
   * Helper: Make HTTP request with error handling
   */
  protected async makeRequest<T>(
    url: string,
    options: RequestInit = {}
  ): Promise<T> {
    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options.headers
      }
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`HTTP ${response.status}: ${error}`);
    }

    return response.json();
  }

  /**
   * Helper: Retry with exponential backoff
   */
  protected async retry<T>(
    fn: () => Promise<T>,
    maxRetries = 3,
    delay = 1000
  ): Promise<T> {
    let lastError: Error;

    for (let i = 0; i < maxRetries; i++) {
      try {
        return await fn();
      } catch (error) {
        lastError = error as Error;
        if (i < maxRetries - 1) {
          await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)));
        }
      }
    }

    throw lastError!;
  }
}

TypeScript Interfaces

// src/lib/server/integrations/types.ts

export interface IntegrationPlugin {
  id: string;                    // Unique identifier (e.g., "stripe")
  name: string;                  // Display name (e.g., "Stripe")
  category: IntegrationCategory;
  icon: string;                  // Icon name or URL
  authType: 'api_key' | 'oauth';
  credentialsSchema: Record<string, CredentialField>;
  metrics: IntegrationMetric[];

  testConnection(credentials: IntegrationCredentials): Promise<TestConnectionResult>;
  fetchMetrics(credentials: IntegrationCredentials, options: FetchMetricsOptions): Promise<FetchMetricsResult>;
}

export type IntegrationCategory =
  | 'payment'
  | 'development'
  | 'analytics'
  | 'marketing'
  | 'communication';

export interface CredentialField {
  label: string;                 // Display label
  placeholder: string;           // Input placeholder
  type: 'text' | 'password' | 'url';
  required: boolean;
  helpText?: string;             // Additional help text
}

export interface IntegrationMetric {
  id: string;                    // Unique metric ID (e.g., "stripe_mrr")
  name: string;                  // Display name
  description: string;           // Description
  unit: string;                  // Unit (e.g., "USD", "count")
  category: string;              // Metric category (e.g., "revenue")
  widgetType: 'metric' | 'chart' | 'table';
}

export type IntegrationCredentials = Record<string, string>;

export interface TestConnectionResult {
  success: boolean;
  error?: string;
  accountInfo?: Record<string, any>;
}

export interface FetchMetricsOptions {
  metricIds: string[];           // Metrics to fetch
  startDate: Date;               // Start of time range
  endDate: Date;                 // End of time range
}

export interface FetchMetricsResult {
  metrics: Array<{
    metricId: string;
    value: number;
    timestamp: number;
    metadata?: Record<string, any>;
  }>;
}

Creating a New Integration

Step 1: Create Plugin File

Create a new file in src/lib/server/integrations/plugins/:
// src/lib/server/integrations/plugins/example.ts
import { BaseIntegration } from '../BaseIntegration';
import type {
  IntegrationCredentials,
  TestConnectionResult,
  FetchMetricsOptions,
  FetchMetricsResult
} from '../types';

export class ExampleIntegration extends BaseIntegration {
  id = 'example';
  name = 'Example Service';
  category = 'analytics' as const;
  icon = 'example-icon';
  authType = 'api_key' as const;

  credentialsSchema = {
    apiKey: {
      label: 'API Key',
      placeholder: 'Enter your Example API key',
      type: 'password' as const,
      required: true,
      helpText: 'Find your API key in Example Settings → API'
    }
  };

  metrics = [
    {
      id: 'example_users',
      name: 'Total Users',
      description: 'Total number of users',
      unit: 'count',
      category: 'users',
      widgetType: 'metric' as const
    },
    {
      id: 'example_revenue',
      name: 'Revenue',
      description: 'Total revenue',
      unit: 'USD',
      category: 'revenue',
      widgetType: 'chart' as const
    }
  ];

  async testConnection(credentials: IntegrationCredentials): Promise<TestConnectionResult> {
    try {
      // Test the API key by making a simple request
      const response = await this.makeRequest<{ account: any }>(
        'https://api.example.com/v1/account',
        {
          headers: {
            'Authorization': `Bearer ${credentials.apiKey}`
          }
        }
      );

      return {
        success: true,
        accountInfo: {
          accountId: response.account.id,
          accountName: response.account.name
        }
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Connection failed'
      };
    }
  }

  async fetchMetrics(
    credentials: IntegrationCredentials,
    options: FetchMetricsOptions
  ): Promise<FetchMetricsResult> {
    const metrics: FetchMetricsResult['metrics'] = [];

    // Fetch each requested metric
    for (const metricId of options.metricIds) {
      try {
        const data = await this.fetchMetric(credentials, metricId, options);
        metrics.push(...data);
      } catch (error) {
        console.error(`Failed to fetch ${metricId}:`, error);
        // Continue with other metrics
      }
    }

    return { metrics };
  }

  private async fetchMetric(
    credentials: IntegrationCredentials,
    metricId: string,
    options: FetchMetricsOptions
  ): Promise<FetchMetricsResult['metrics']> {
    switch (metricId) {
      case 'example_users':
        return this.fetchUsers(credentials, options);
      case 'example_revenue':
        return this.fetchRevenue(credentials, options);
      default:
        throw new Error(`Unknown metric: ${metricId}`);
    }
  }

  private async fetchUsers(
    credentials: IntegrationCredentials,
    options: FetchMetricsOptions
  ): Promise<FetchMetricsResult['metrics']> {
    const response = await this.makeRequest<{ users: number }>(
      'https://api.example.com/v1/stats/users',
      {
        headers: {
          'Authorization': `Bearer ${credentials.apiKey}`
        }
      }
    );

    return [{
      metricId: 'example_users',
      value: response.users,
      timestamp: Date.now(),
      metadata: {}
    }];
  }

  private async fetchRevenue(
    credentials: IntegrationCredentials,
    options: FetchMetricsOptions
  ): Promise<FetchMetricsResult['metrics']> {
    // Fetch time series data
    const response = await this.makeRequest<{ data: Array<{ date: string, amount: number }> }>(
      `https://api.example.com/v1/stats/revenue?start=${options.startDate.toISOString()}&end=${options.endDate.toISOString()}`,
      {
        headers: {
          'Authorization': `Bearer ${credentials.apiKey}`
        }
      }
    );

    return response.data.map(item => ({
      metricId: 'example_revenue',
      value: item.amount,
      timestamp: new Date(item.date).getTime(),
      metadata: {}
    }));
  }
}

Step 2: Register Plugin

Add your plugin to the registry:
// src/lib/server/integrations/registry.ts
import { StripeIntegration } from './plugins/stripe';
import { GitHubIntegration } from './plugins/github';
import { VercelIntegration } from './plugins/vercel';
import { GoogleAnalyticsIntegration } from './plugins/google-analytics';
import { PlausibleIntegration } from './plugins/plausible';
import { ExampleIntegration } from './plugins/example'; // Add this

const integrations = [
  new StripeIntegration(),
  new GitHubIntegration(),
  new VercelIntegration(),
  new GoogleAnalyticsIntegration(),
  new PlausibleIntegration(),
  new ExampleIntegration() // Add this
];

export const integrationRegistry = new Map(
  integrations.map(integration => [integration.id, integration])
);

export function getIntegration(id: string) {
  const integration = integrationRegistry.get(id);
  if (!integration) {
    throw new Error(`Integration not found: ${id}`);
  }
  return integration;
}

export function getAllIntegrations() {
  return Array.from(integrationRegistry.values());
}

Step 3: Add Icon

Add an icon for your integration in the UI components.

Step 4: Test Integration

Create tests for your integration:
// src/lib/server/integrations/plugins/example.test.ts
import { describe, it, expect, vi } from 'vitest';
import { ExampleIntegration } from './example';

describe('ExampleIntegration', () => {
  const integration = new ExampleIntegration();

  it('should have correct metadata', () => {
    expect(integration.id).toBe('example');
    expect(integration.name).toBe('Example Service');
    expect(integration.category).toBe('analytics');
  });

  it('should test connection successfully', async () => {
    // Mock fetch
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ account: { id: '123', name: 'Test' } })
    });

    const result = await integration.testConnection({ apiKey: 'test-key' });

    expect(result.success).toBe(true);
    expect(result.accountInfo).toEqual({
      accountId: '123',
      accountName: 'Test'
    });
  });

  it('should fetch metrics', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => ({ users: 1000 })
    });

    const result = await integration.fetchMetrics(
      { apiKey: 'test-key' },
      {
        metricIds: ['example_users'],
        startDate: new Date('2024-01-01'),
        endDate: new Date('2024-01-31')
      }
    );

    expect(result.metrics).toHaveLength(1);
    expect(result.metrics[0].metricId).toBe('example_users');
    expect(result.metrics[0].value).toBe(1000);
  });
});

OAuth Integrations

For OAuth-based integrations (like Google Analytics):
export class OAuthIntegration extends BaseIntegration {
  authType = 'oauth' as const;

  credentialsSchema = {
    accessToken: {
      label: 'Access Token',
      placeholder: 'OAuth access token',
      type: 'password' as const,
      required: true
    },
    refreshToken: {
      label: 'Refresh Token',
      placeholder: 'OAuth refresh token',
      type: 'password' as const,
      required: true
    }
  };

  async testConnection(credentials: IntegrationCredentials): Promise<TestConnectionResult> {
    try {
      // Verify access token
      const response = await this.makeRequest<{ user: any }>(
        'https://api.example.com/v1/me',
        {
          headers: {
            'Authorization': `Bearer ${credentials.accessToken}`
          }
        }
      );

      return {
        success: true,
        accountInfo: {
          userId: response.user.id,
          email: response.user.email
        }
      };
    } catch (error) {
      // Try to refresh token
      try {
        const newToken = await this.refreshAccessToken(credentials.refreshToken);
        // Update credentials in database
        return {
          success: true,
          accountInfo: { refreshed: true }
        };
      } catch (refreshError) {
        return {
          success: false,
          error: 'OAuth token expired. Please reconnect.'
        };
      }
    }
  }

  private async refreshAccessToken(refreshToken: string): Promise<string> {
    const response = await this.makeRequest<{ access_token: string }>(
      'https://api.example.com/oauth/token',
      {
        method: 'POST',
        body: JSON.stringify({
          grant_type: 'refresh_token',
          refresh_token: refreshToken,
          client_id: process.env.EXAMPLE_CLIENT_ID,
          client_secret: process.env.EXAMPLE_CLIENT_SECRET
        })
      }
    );

    return response.access_token;
  }
}

Best Practices

Error Handling

  1. Catch and log errors: Don’t let errors crash the sync
  2. Return partial results: If some metrics fail, return the successful ones
  3. Provide helpful error messages: Help users debug connection issues
  4. Retry transient failures: Use exponential backoff for rate limits

Rate Limiting

  1. Respect API limits: Check integration’s rate limits
  2. Implement backoff: Wait when rate limited
  3. Batch requests: Combine multiple metrics when possible
  4. Cache responses: Avoid redundant API calls

Data Quality

  1. Validate responses: Check data types and ranges
  2. Handle missing data: Provide defaults or skip
  3. Normalize units: Convert to standard units (e.g., cents to dollars)
  4. Include metadata: Store additional context

Security

  1. Never log credentials: Credentials are encrypted in database
  2. Use HTTPS only: All API calls must use HTTPS
  3. Validate input: Check credentials format before using
  4. Handle token refresh: Implement OAuth token refresh

Testing Integrations

Unit Tests

Test individual methods:
describe('fetchMetrics', () => {
  it('should fetch all requested metrics', async () => {
    // Test implementation
  });

  it('should handle API errors gracefully', async () => {
    // Test error handling
  });

  it('should retry on rate limit', async () => {
    // Test retry logic
  });
});

Integration Tests

Test against real APIs (use test accounts):
describe('ExampleIntegration (integration)', () => {
  it('should connect to real API', async () => {
    const integration = new ExampleIntegration();
    const result = await integration.testConnection({
      apiKey: process.env.EXAMPLE_TEST_API_KEY!
    });
    expect(result.success).toBe(true);
  });
});

Manual Testing

  1. Create a test project in Dashtray
  2. Connect your integration
  3. Verify metrics appear correctly
  4. Test error scenarios (invalid credentials, rate limits)
  5. Check sync frequency and reliability

Deployment

  1. Add environment variables: If needed for OAuth
  2. Update documentation: Add user guide for the integration
  3. Monitor errors: Watch for connection failures
  4. Gather feedback: Iterate based on user reports

Need Help?

  • Integration Examples: See existing plugins in src/lib/server/integrations/plugins/
  • Base Class: src/lib/server/integrations/BaseIntegration.ts
  • Types: src/lib/server/integrations/types.ts
  • Tests: src/lib/server/integrations/BaseIntegration.test.ts