@varia-bly/variably-sdk
Version:
Official JavaScript/TypeScript SDK for Variably feature flags, experimentation, LLM experiments with React hooks, and real-time dynamic configurations
488 lines • 15.7 kB
JavaScript
/**
* GraphQL client for Variably API
*/
import { NetworkError, AuthenticationError, RateLimitError, TimeoutError, ValidationError } from './errors';
export class GraphQLClient {
constructor(config, logger, metrics) {
this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.apiKey = config.apiKey;
this.timeout = config.timeout;
this.retryAttempts = config.retryAttempts;
this.logger = logger;
this.metrics = metrics;
}
/**
* Evaluate a single feature flag
*/
async evaluateFlag(flagKey, context) {
const query = `
query SdkEvaluateFlag($input: SDKFlagEvaluationInput!) {
sdkEvaluateFlag(input: $input) {
flagKey
value
reason
ruleId
cacheHit
evaluatedAt
}
}
`;
const variables = {
input: {
flagKey,
context: this.convertUserContext(context)
}
};
const response = await this.executeQuery({ query, variables });
const result = response.sdkEvaluateFlag;
return {
flag_key: result.flagKey,
value: result.value,
reason: result.reason,
rule_id: result.ruleId
};
}
/**
* Evaluate multiple feature flags in batch
*/
async evaluateFlags(flagKeys, context) {
const query = `
query SdkEvaluateFlags($input: SDKBatchFlagEvaluationInput!) {
sdkEvaluateFlags(input: $input) {
results {
flagKey
value
reason
ruleId
cacheHit
evaluatedAt
}
timestamp
}
}
`;
const variables = {
input: {
flagKeys,
context: this.convertUserContext(context)
}
};
const response = await this.executeQuery({ query, variables });
const batchResult = response.sdkEvaluateFlags;
const results = {};
for (const result of batchResult.results) {
results[result.flagKey] = {
flag_key: result.flagKey,
value: result.value,
reason: result.reason,
rule_id: result.ruleId
};
}
return { results };
}
/**
* Evaluate a feature gate
*/
async evaluateGate(gateKey, context) {
const query = `
query SdkEvaluateGate($input: SDKGateEvaluationInput!) {
sdkEvaluateGate(input: $input) {
gateKey
value
reason
ruleId
environment
experimentId
variantId
isControl
successMetrics
cacheHit
evaluatedAt
}
}
`;
const variables = {
input: {
gateKey,
context: this.convertUserContext(context)
}
};
const response = await this.executeQuery({ query, variables });
const result = response.sdkEvaluateGate;
return {
gate_key: result.gateKey,
value: result.value,
reason: result.reason,
rule_id: result.ruleId,
environment: result.environment,
experiment_id: result.experimentId,
variant_id: result.variantId,
is_control: result.isControl,
success_metrics: result.successMetrics || []
};
}
/**
* Track a single event
*/
async trackEvent(event) {
const mutation = `
mutation SdkTrackEvent($input: SDKEventInput!) {
sdkTrackEvent(input: $input)
}
`;
const variables = {
input: {
name: event.name,
userId: event.userId,
sessionId: event.sessionId,
properties: event.properties,
timestamp: event.timestamp || new Date()
}
};
await this.executeMutation({ query: mutation, variables });
}
/**
* Track multiple events in batch
*/
async trackEvents(events) {
const mutation = `
mutation SdkTrackEvents($inputs: [SDKEventInput!]!) {
sdkTrackEvents(inputs: $inputs)
}
`;
const variables = {
inputs: events.map(event => ({
name: event.name,
userId: event.userId,
sessionId: event.sessionId,
properties: event.properties,
timestamp: event.timestamp || new Date()
}))
};
await this.executeMutation({ query: mutation, variables });
}
/**
* Track experiment metric
*/
async trackExperimentMetric(experimentId, metric) {
const mutation = `
mutation SdkTrackExperimentMetric($input: SDKExperimentMetricInput!) {
sdkTrackExperimentMetric(input: $input)
}
`;
const variables = {
input: {
experimentId,
userId: metric.userId,
metricKey: metric.metricKey,
value: metric.value,
metadata: metric.metadata,
sessionId: metric.sessionId,
timestamp: metric.timestamp || new Date()
}
};
await this.executeMutation({ query: mutation, variables });
}
/**
* Logout and blacklist current token
*/
async logout() {
const mutation = `
mutation SdkLogout {
sdkLogout {
success
message
}
}
`;
await this.executeMutation({ query: mutation });
}
/**
* Execute LLM prompt
*/
async executeLLMPrompt(messages, provider, model, temperature, maxTokens) {
const mutation = `
mutation SdkExecuteLLMPrompt($input: SDKLLMExecutionInput!) {
sdkExecuteLLMPrompt(input: $input) {
content
model
provider
tokenUsage {
promptTokens
completionTokens
totalTokens
estimatedCost
}
finishReason
}
}
`;
const variables = {
input: {
messages,
provider,
model,
temperature: temperature || 0.7,
maxTokens: maxTokens || 2000
}
};
const response = await this.executeMutation({ query: mutation, variables });
return response.sdkExecuteLLMPrompt;
}
/**
* Evaluate a prompt experiment - selects variant, executes LLM, and saves evaluation
* This is the recommended method for prompt experiments as it saves evaluation data
*/
async evaluatePromptExperiment(experimentKey, context, inputVariables, metadata) {
const mutation = `
mutation SdkEvaluatePromptExperiment($input: SDKPromptEvaluationInput!) {
sdkEvaluatePromptExperiment(input: $input) {
executionId
experimentId
variantUsed
content
model
provider
tokenUsage {
promptTokens
completionTokens
totalTokens
estimatedCost
}
latencyMs
qualityScore
}
}
`;
const variables = {
input: {
experimentKey,
context: this.convertUserContext(context),
inputVariables: inputVariables || {},
metadata: metadata || {},
}
};
const response = await this.executeMutation({ query: mutation, variables });
return response.sdkEvaluatePromptExperiment;
}
/**
* Execute a GraphQL query
*/
async executeQuery(request) {
return this.executeGraphQL(request);
}
/**
* Execute a GraphQL mutation
*/
async executeMutation(request) {
return this.executeGraphQL(request);
}
/**
* Execute a GraphQL request with retry logic
*/
async executeGraphQL(request) {
const url = `${this.baseUrl}/graphql`;
let lastError;
for (let attempt = 0; attempt <= this.retryAttempts; attempt++) {
try {
const startTime = Date.now();
const response = await this.makeRequest(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.apiKey,
'User-Agent': 'Variably-JS-SDK/2.0.0'
},
body: JSON.stringify(request)
});
const duration = Date.now() - startTime;
this.metrics.recordLatency(duration);
this.metrics.recordApiCall();
if (response == null) {
throw new Error('No response received from server');
}
if (!response.ok) {
const errorText = await response.text();
this.handleHttpError(response.status, url, errorText);
}
const result = await response.json();
// Handle GraphQL errors
if (result.errors && result.errors.length > 0) {
const error = result.errors[0];
throw new Error(`GraphQL Error: ${error.message}`);
}
if (!result.data) {
throw new Error('GraphQL response missing data');
}
this.logger.debug('GraphQL request successful', {
operation: request.operationName || 'unknown',
duration,
attempt: attempt + 1
});
return result.data;
}
catch (error) {
lastError = error;
this.metrics.recordError();
this.logger.warn('GraphQL request failed', {
operation: request.operationName || 'unknown',
error: error instanceof Error ? error.message : String(error),
attempt: attempt + 1
});
// Don't retry on certain errors
if (!this.shouldRetry(error instanceof Error ? error : new Error(String(error)), attempt)) {
break;
}
// Wait before retry with exponential backoff
if (attempt < this.retryAttempts) {
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
await this.sleep(delay);
}
}
}
throw lastError || new Error('Unknown error occurred');
}
/**
* Execute the actual HTTP request
*/
async makeRequest(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
return response;
}
catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new TimeoutError(`Request timeout after ${this.timeout}ms`, 'graphql_request');
}
throw error;
}
finally {
clearTimeout(timeoutId);
}
}
/**
* Handle HTTP error responses
*/
handleHttpError(status, url, errorText) {
let errorMessage;
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.message || errorData.error || errorText;
}
catch {
errorMessage = errorText || `HTTP ${status}`;
}
switch (status) {
case 400:
throw new ValidationError(`Bad Request: ${errorMessage}`, 'request');
case 401:
throw new AuthenticationError(`Unauthorized: ${errorMessage}`);
case 403:
throw new AuthenticationError(`Forbidden: ${errorMessage}`);
case 404:
throw new NetworkError(`Not Found: ${errorMessage}`, status, url);
case 429:
const retryAfter = 60; // Default retry after 60 seconds
throw new RateLimitError(`Rate limited: ${errorMessage}`, retryAfter);
case 422:
throw new ValidationError(`Validation Error: ${errorMessage}`, 'validation');
default:
throw new NetworkError(`HTTP ${status}: ${errorMessage}`, status, url);
}
}
/**
* Determine if an error should trigger a retry
*/
shouldRetry(error, attempt) {
if (attempt >= this.retryAttempts) {
return false;
}
// Don't retry authentication errors
if (error instanceof AuthenticationError) {
return false;
}
// Don't retry validation errors
if (error instanceof ValidationError) {
return false;
}
// Retry network errors with retryable status codes
if (error instanceof NetworkError) {
return NetworkError.isRetryable(error.statusCode);
}
// Retry rate limit errors
if (error instanceof RateLimitError) {
return true;
}
// Retry timeout errors
if (error instanceof TimeoutError) {
return true;
}
// Retry other network-related errors
if (error.message.includes('fetch') || error.message.includes('network')) {
return true;
}
return false;
}
/**
* Convert SDK UserContext to GraphQL input format
*/
convertUserContext(context) {
return {
userId: context.userId,
email: context.email,
country: context.country,
language: context.language,
platform: context.platform,
version: context.version,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
sessionId: context.sessionId,
attributes: context.attributes
};
}
/**
* Track a success metric for an LLM prompt experiment
* Uses the sdkTrackExperimentMetric GraphQL mutation
*/
async trackSuccessMetric(experimentId, metricName, metricKey, userId, sessionId, variantId, variantKey, value, metadata) {
const mutation = `
mutation SdkTrackExperimentMetric($input: SDKExperimentMetricInput!) {
sdkTrackExperimentMetric(input: $input)
}
`;
const variables = {
input: {
experimentId,
metricName,
metricKey,
userId,
sessionId,
variantId,
variantKey,
value: value !== undefined ? value : 1.0,
metadata: metadata || {}
}
};
this.logger.debug('Tracking success metric via GraphQL', {
experimentId,
metricKey,
userId,
hasSessionId: !!sessionId,
hasVariantId: !!variantId,
});
await this.executeMutation({ query: mutation, variables });
this.logger.debug('Success metric tracked successfully');
}
/**
* Sleep for a given number of milliseconds
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
//# sourceMappingURL=graphql-client.js.map