← vibesdk
worker/services/analytics/AiGatewayAnalyticsService.ts
12.6 KB
Download file
/**
* AI Gateway Analytics Service
* Provides analytics data from Cloudflare AI Gateway GraphQL API
*/
import { createLogger, StructuredLogger } from '../../logger';
import {
AnalyticsConfig,
AnalyticsData,
UserAnalyticsData,
ChatAnalyticsData,
TimeRange,
CloudflareAnalyticsResponse,
GraphQLQueryVariables,
GraphQLQuery,
QueryResult,
AnalyticsError,
AnalyticsQueryType
} from './types';
export class AiGatewayAnalyticsService {
private config: AnalyticsConfig;
private logger: StructuredLogger;
constructor(env: Env) {
this.logger = createLogger('AiGatewayAnalyticsService');
this.config = this.initializeConfig(env);
this.logger.info('AiGatewayAnalyticsService initialized', {
endpoint: this.config.graphqlEndpoint,
isStaging: this.config.isStaging,
accountId: this.config.accountId,
gateway: this.config.gateway
});
}
/**
* Initialize configuration from environment variables
*/
private initializeConfig(env: Env): AnalyticsConfig {
const config: AnalyticsConfig = {
accountId: '',
gateway: '',
graphqlEndpoint: 'https://api.cloudflare.com/client/v4/graphql',
apiToken: '',
isStaging: false
};
// If CLOUDFLARE_AI_GATEWAY_URL is set, parse it and use staging settings
if (env.CLOUDFLARE_AI_GATEWAY_URL) {
try {
const { accountId, gateway, isStaging } = this.parseGatewayUrl(env.CLOUDFLARE_AI_GATEWAY_URL);
config.accountId = accountId;
config.gateway = gateway;
config.isStaging = isStaging;
if (isStaging) {
config.graphqlEndpoint = 'https://api.staging.cloudflare.com/client/v4/graphql';
config.apiToken = env.CLOUDFLARE_AI_GATEWAY_TOKEN || env.CLOUDFLARE_API_TOKEN || '';
} else {
config.apiToken = env.CLOUDFLARE_API_TOKEN || '';
}
} catch (error) {
this.logger.warn('Failed to parse CLOUDFLARE_AI_GATEWAY_URL, falling back to direct env vars', { error });
// Fall through to direct env vars
}
}
// Use direct environment variables if gateway URL not set or parsing failed
if (!config.accountId || !config.gateway || !config.apiToken) {
config.accountId = env.CLOUDFLARE_ACCOUNT_ID || config.accountId;
config.gateway = env.CLOUDFLARE_AI_GATEWAY || config.gateway;
config.apiToken = env.CLOUDFLARE_API_TOKEN || config.apiToken;
}
// Validate required configuration
if (!config.accountId || !config.gateway || !config.apiToken) {
const missing = [];
if (!config.accountId) missing.push('Account ID');
if (!config.gateway) missing.push('Gateway name');
if (!config.apiToken) missing.push('API token');
throw new AnalyticsError(
`Missing required configuration: ${missing.join(', ')}`,
'CONFIG_MISSING',
500
);
}
return config;
}
/**
* Parse AI Gateway URL to extract account ID and gateway name
*/
private parseGatewayUrl(url: string): { accountId: string; gateway: string; isStaging: boolean } {
try {
const parsedUrl = new URL(url);
const isStaging = url.includes('staging');
// URL format: https://staging.gateway.ai.cfdata.org/v1/{account_id}/{gateway_name}
const pathParts = parsedUrl.pathname.split('/').filter(part => part);
if (pathParts.length >= 3 && pathParts[0] === 'v1') {
return {
accountId: pathParts[1],
gateway: pathParts[2],
isStaging
};
}
throw new Error('Invalid gateway URL format');
} catch (error) {
throw new AnalyticsError(
`Failed to parse gateway URL: ${error}`,
'INVALID_URL',
400,
error as Error
);
}
}
/**
* Generate time range for analytics queries
* If no days specified, returns maximum allowed range (30 days due to API limits)
*/
private getTimeRange(days?: number): TimeRange {
const end = new Date();
// Cloudflare AI Gateway API has a time range limit (~32 days max)
// If no days specified, use 30 days to stay within limits
const daysToQuery = days || 30;
const start = new Date(end.getTime() - (daysToQuery * 24 * 60 * 60 * 1000));
// Use the exact format from working examples
const offsetMinutes = end.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(offsetMinutes) / 60);
const offsetMins = Math.abs(offsetMinutes) % 60;
const offsetSign = offsetMinutes <= 0 ? '+' : '-';
const offsetStr = `${offsetSign}${offsetHours.toString().padStart(2, '0')}:${offsetMins.toString().padStart(2, '0')}`;
// Set start time to beginning of the day
const startOfDay = new Date(start);
startOfDay.setHours(0, 0, 0, 0);
return {
start: startOfDay.toISOString().slice(0, 19) + offsetStr,
end: end.toISOString().slice(0, 19) + offsetStr
};
}
/**
* Execute GraphQL query against Cloudflare Analytics API
*/
private async executeQuery(query: GraphQLQuery): Promise<QueryResult> {
const startTime = Date.now();
try {
const response = await fetch(this.config.graphqlEndpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.config.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(query),
});
const responseTime = Date.now() - startTime;
if (!response.ok) {
const errorText = await response.text();
throw new AnalyticsError(
`HTTP ${response.status}: ${errorText}`,
'HTTP_ERROR',
response.status
);
}
const data = await response.json() as CloudflareAnalyticsResponse;
// Check for GraphQL errors
if (data.errors && data.errors.length > 0) {
const error = data.errors[0];
throw new AnalyticsError(
error.message || 'GraphQL query failed',
error.extensions?.code || 'GRAPHQL_ERROR',
error.extensions?.code === 'authz' ? 403 : 500
);
}
return {
data,
responseTime,
};
} catch (error) {
const responseTime = Date.now() - startTime;
if (error instanceof AnalyticsError) {
return {
data: null,
responseTime,
error: error.message
};
}
return {
data: null,
responseTime,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Build GraphQL query for specific filter type
*/
private buildQuery(type: AnalyticsQueryType, timeRange: TimeRange, filterId?: string): GraphQLQuery {
const variables: GraphQLQueryVariables = {
accountTag: this.config.accountId,
gateway: this.config.gateway,
start: timeRange.start,
end: timeRange.end,
limit: 1
};
let filterClause = '';
switch (type) {
case 'user':
filterClause = 'metadataKeys_has: "userId",';
break;
case 'chat':
filterClause = 'metadataKeys_has: "chatId",';
break;
case 'specific':
if (!filterId) throw new AnalyticsError('Filter ID required for specific queries', 'MISSING_FILTER_ID', 400);
filterClause = `metadataValues_has: "${filterId}",`;
break;
case 'total':
default:
// No additional filter for total analytics
break;
}
return {
operationName: null,
variables,
query: `{
viewer {
scope: accounts(filter: {accountTag: $accountTag}) {
latestRequests: aiGatewayRequestsAdaptiveGroups(limit: $limit, filter: {gateway: $gateway, ${filterClause} datetimeHour_geq: $start, datetimeHour_leq: $end}) {
count
dimensions {
ts: datetimeHour
__typename
}
__typename
}
lastRequest: aiGatewayRequestsAdaptiveGroups(limit: 1, orderBy: [datetimeMinute_DESC], filter: {gateway: $gateway, ${filterClause} datetimeHour_geq: $start, datetimeHour_leq: $end}) {
dimensions {
ts: datetimeMinute
__typename
}
__typename
}
totalRequests: aiGatewayRequestsAdaptiveGroups(limit: $limit, filter: {gateway: $gateway, ${filterClause} datetimeHour_geq: $start, datetimeHour_leq: $end}) {
count
sum {
cost
cachedRequests
erroredRequests
uncachedTokensIn
uncachedTokensOut
cachedTokensIn
cachedTokensOut
__typename
}
__typename
}
__typename
}
__typename
}
}`
};
}
/**
* Process raw analytics response into structured data
*/
private processAnalyticsResponse(result: QueryResult, timeRange: TimeRange): AnalyticsData {
if (result.error || !result.data?.data?.viewer?.scope?.[0]) {
throw new AnalyticsError(
result.error || 'No analytics data available',
'NO_DATA',
404
);
}
const scope = result.data.data.viewer.scope[0];
const totalRequests = scope.totalRequests?.[0];
const lastRequest = scope.lastRequest?.[0];
const latestRequests = scope.latestRequests?.[0];
if (!totalRequests) {
throw new AnalyticsError('No request data available', 'NO_REQUEST_DATA', 404);
}
const {
cost,
cachedRequests,
erroredRequests,
uncachedTokensIn,
uncachedTokensOut,
cachedTokensIn,
cachedTokensOut
} = totalRequests.sum;
const totalTokensIn = uncachedTokensIn + cachedTokensIn;
const totalTokensOut = uncachedTokensOut + cachedTokensOut;
const errorRate = totalRequests.count > 0 ? ((erroredRequests / totalRequests.count) * 100) : 0;
const cacheHitRate = totalRequests.count > 0 ? ((cachedRequests / totalRequests.count) * 100) : 0;
return {
totalRequests: totalRequests.count,
totalCost: cost,
tokensIn: totalTokensIn,
tokensOut: totalTokensOut,
errorRate: parseFloat(errorRate.toFixed(2)),
cacheHitRate: parseFloat(cacheHitRate.toFixed(2)),
cachedRequests,
erroredRequests,
lastRequestAt: lastRequest?.dimensions?.ts || null,
latestHourActivity: latestRequests ? {
count: latestRequests.count,
timestamp: latestRequests.dimensions.ts
} : null,
timeRange,
queryResponseTime: result.responseTime
};
}
/**
* Get analytics data for a specific user
* @param userId - User ID to filter by
* @param days - Number of days to query (optional, defaults to 30 days due to API limits)
*/
async getUserAnalytics(userId: string, days?: number): Promise<UserAnalyticsData> {
this.logger.info('Getting user analytics', { userId, days: days || '30 days (default)' });
const timeRange = this.getTimeRange(days);
const query = this.buildQuery('specific', timeRange, userId);
const result = await this.executeQuery(query);
const analyticsData = this.processAnalyticsResponse(result, timeRange);
return {
...analyticsData,
userId
};
}
/**
* Get analytics data for a specific chat/agent
* @param chatId - Chat/Agent ID to filter by
* @param days - Number of days to query (optional, defaults to 30 days due to API limits)
*/
async getChatAnalytics(chatId: string, days?: number): Promise<ChatAnalyticsData> {
this.logger.info('Getting chat analytics', { chatId, days: days || '30 days (default)' });
const timeRange = this.getTimeRange(days);
const query = this.buildQuery('specific', timeRange, chatId);
const result = await this.executeQuery(query);
const analyticsData = this.processAnalyticsResponse(result, timeRange);
return {
...analyticsData,
chatId
};
}
/**
* Get total gateway analytics (for debugging/admin purposes)
* @param days - Number of days to query (optional, defaults to 30 days due to API limits)
*/
async getTotalAnalytics(days?: number): Promise<AnalyticsData> {
this.logger.info('Getting total analytics', { days: days || '30 days (default)' });
const timeRange = this.getTimeRange(days);
const query = this.buildQuery('total', timeRange);
const result = await this.executeQuery(query);
return this.processAnalyticsResponse(result, timeRange);
}
}