Skip to content
Open
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
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[*.*]
indent_style = space
indent_size = 2
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The best way to use @opik/ccsync is with **Claude Code hooks** for automatic syn
```bash
npx @opik/ccsync config
```

This will guide you through an interactive setup process. Alternatively, you can set environment variables:
```bash
export OPIK_API_KEY="your-api-key"
Expand Down Expand Up @@ -78,6 +78,7 @@ Configure Opik connection using environment variables or a config file:
export OPIK_API_KEY="your-api-key"
export OPIK_BASE_URL="http://localhost:5173" # Optional
export OPIK_PROJECT_NAME="your-project" # Optional
export OPIK_PROVIDER="anthropic" # Optional
```

### Configuration File
Expand All @@ -86,6 +87,7 @@ Create `~/.opik.config`:
api_key = your-api-key
url_override = http://localhost:5173
workspace = your-workspace
provider = anthropic
```

## Commands
Expand Down Expand Up @@ -286,7 +288,7 @@ Add hooks to your Claude Code settings file (`~/.claude/settings.json` or `.clau
],
"Stop": [
{
"matcher": "*",
"matcher": "*",
"hooks": [
{
"type": "command",
Expand All @@ -310,7 +312,7 @@ Add hooks to your Claude Code settings file (`~/.claude/settings.json` or `.clau
"matcher": "*",
"hooks": [
{
"type": "command",
"type": "command",
"command": "jq -r '.session_id' | xargs -I {} npx @opik/ccsync sync --session {} --force"
}
]
Expand Down
130 changes: 110 additions & 20 deletions src/api/opik-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
import { OpikConfig, OpikTrace } from '../types';
import { OpikConfig, OpikSpan, OpikTrace } from '../types';
import { createLogger } from '../utils/logger';

export interface OpikCreateTracesRequest {
Expand All @@ -14,6 +14,17 @@ export interface OpikCreateTracesResponse {
[key: string]: any; // Allow additional properties
}

export interface OpikCreateSpansRequest {
spans: OpikSpan[];
}

export interface OpikCreateSpansResponse {
spans?: Array<{
id: string;
project_id?: string;
}>;
}

export class OpikApiClient {
private client: AxiosInstance;
private config: OpikConfig;
Expand All @@ -22,15 +33,15 @@ export class OpikApiClient {
constructor(config: OpikConfig) {
this.config = config;
this.logger = createLogger({ verbose: false });

// Normalize the base URL - remove trailing /api/ if present since we'll add the full path
let baseURL = config.base_url;
if (baseURL.endsWith('/api/')) {
baseURL = baseURL.slice(0, -5); // Remove '/api/'
} else if (baseURL.endsWith('/api')) {
baseURL = baseURL.slice(0, -4); // Remove '/api'
}

this.client = axios.create({
baseURL: baseURL,
headers: {
Expand All @@ -41,7 +52,7 @@ export class OpikApiClient {
},
timeout: 30000 // 30 second timeout
});

this.logger.debug(`Opik API client configured with base URL: ${baseURL}`);
}

Expand All @@ -50,12 +61,12 @@ export class OpikApiClient {
if (traces.length === 0) {
return { traces: [] };
}

const request: OpikCreateTracesRequest = { traces };

try {
this.logger.debug(`Sending ${traces.length} traces to Opik at ${this.config.base_url}`);

const response = await this.client.post<OpikCreateTracesResponse>(
'/api/v1/private/traces/batch',
request
Expand All @@ -68,12 +79,12 @@ export class OpikApiClient {
} catch (error) {
if (error instanceof AxiosError) {
// If workspace doesn't exist, try with "default"
if (error.response?.status === 403 &&
error.response?.data?.message?.includes('Workspace') &&
this.config.workspace !== 'default') {
if (error.response?.status === 403 &&
error.response?.data?.message?.includes('Workspace') &&
this.config.workspace !== 'default') {

this.logger.warning(`Workspace '${this.config.workspace}' not found, trying 'default'`);

// Create a new client with default workspace
const defaultClient = axios.create({
...this.client.defaults,
Expand All @@ -82,7 +93,7 @@ export class OpikApiClient {
'Comet-Workspace': 'default'
}
});

try {
const response = await defaultClient.post<OpikCreateTracesResponse>(
'/api/v1/private/traces/batch',
Expand All @@ -96,14 +107,14 @@ export class OpikApiClient {
this.logger.error(`Fallback to default workspace also failed`);
}
}

this.logger.debug(`Request details:`, {
url: error.config?.url,
method: error.config?.method,
baseURL: error.config?.baseURL,
headers: error.config?.headers
});

const status = error.response?.status || 'no response';
const statusText = error.response?.statusText || 'unknown error';
const errorMsg = `Failed to create traces: ${status} ${statusText}`;
Expand All @@ -114,6 +125,85 @@ export class OpikApiClient {
}
}

async createSpans(spans: OpikSpan[]): Promise<OpikCreateSpansResponse> {
// Don't call API if there are no spans to create
if (spans.length === 0) {
return { spans: [] };
}

const limit = 1000; // Opik API prohibits more than 1000 spans at a time
const chunks = [...Array(Math.ceil(spans.length / limit))].map(_ => spans.splice(0, limit));
const response: OpikCreateSpansResponse = { spans: [] };

for (const chunkSpans of chunks) {
const request: OpikCreateSpansRequest = { spans: chunkSpans };

try {
this.logger.debug(`Sending ${chunkSpans.length} spans to Opik at ${this.config.base_url}`);

const chunkResponse = await this.client.post<OpikCreateSpansResponse>(
'/api/v1/private/spans/batch',
request
);

this.logger.debug(`Status Code: ${chunkResponse.status}`);
this.logger.debug(`API Response:`, chunkResponse.data);
this.logger.debug(`Successfully created ${chunkResponse.data?.spans?.length || 'unknown number of'} spans`);

response.spans?.push(...(chunkResponse.data.spans ?? []));
} catch (error) {
if (error instanceof AxiosError) {
// If workspace doesn't exist, try with "default"
if (error.response?.status === 403 &&
error.response?.data?.message?.includes('Workspace') &&
this.config.workspace !== 'default') {

this.logger.warning(`Workspace '${this.config.workspace}' not found, trying 'default'`);

// Create a new client with default workspace
const defaultClient = axios.create({
...this.client.defaults,
headers: {
...this.client.defaults.headers,
'Comet-Workspace': 'default'
}
});

try {
const chunkResponse = await defaultClient.post<OpikCreateSpansResponse>(
'/api/v1/private/spans/batch',
request
);
this.logger.debug(`Fallback Status Code: ${chunkResponse.status}`);
this.logger.debug(`Fallback API Response:`, chunkResponse.data);
this.logger.debug(`Successfully created ${chunkResponse.data?.spans?.length || 'unknown number of'} spans in default workspace`);

response.spans?.push(...(chunkResponse.data.spans ?? []));
} catch (fallbackError) {
this.logger.error(`Fallback to default workspace also failed`);
}
}

this.logger.debug(`Request details:`, {
url: error.config?.url,
method: error.config?.method,
baseURL: error.config?.baseURL,
headers: error.config?.headers
});

const status = error.response?.status || 'no response';
const statusText = error.response?.statusText || 'unknown error';
const errorMsg = `Failed to create spans: ${status} ${statusText}`;
const errorDetails = error.response?.data ? JSON.stringify(error.response.data, null, 2) : error.message;
throw new Error(`${errorMsg}\nDetails: ${errorDetails}`);
}
throw new Error(`Unexpected error creating spans: ${error instanceof Error ? error.message : error}`);
}
}

return response;
}

async testConnection(): Promise<boolean> {
try {
// Test with empty traces array to validate connection
Expand Down Expand Up @@ -144,7 +234,7 @@ export class OpikApiClient {
async updateTrace(traceId: string, trace: Partial<OpikTrace>): Promise<void> {
try {
this.logger.debug(`Updating trace ${traceId} in Opik`);

const response = await this.client.patch<void>(
`/api/v1/private/traces/${traceId}`,
trace
Expand All @@ -160,7 +250,7 @@ export class OpikApiClient {
baseURL: error.config?.baseURL,
headers: error.config?.headers
});

const status = error.response?.status || 'no response';
const statusText = error.response?.statusText || 'unknown error';
const errorMsg = `Failed to update trace ${traceId}: ${status} ${statusText}`;
Expand Down Expand Up @@ -195,7 +285,7 @@ export class OpikApiClient {
});

const searchResponse = await this.client.get(`${searchEndpoint}?${searchParams}`);

if (!searchResponse.data?.content?.[0]?.thread_model_id) {
throw new Error(`Thread ${threadId} not found or no thread_model_id available`);
}
Expand All @@ -205,7 +295,7 @@ export class OpikApiClient {
// Now update the thread tags using the thread_model_id
const updateEndpoint = `/api/v1/private/traces/threads/${threadModelId}`;
const payload = { tags };

await this.client.patch<void>(updateEndpoint, payload);
} catch (error) {
if (error instanceof AxiosError) {
Expand All @@ -216,7 +306,7 @@ export class OpikApiClient {
headers: error.config?.headers,
data: error.config?.data
})}`);

const status = error.response?.status || 'no response';
const statusText = error.response?.statusText || 'unknown error';
const errorMsg = `Failed to update thread ${threadId} tags: ${status} ${statusText}`;
Expand Down
37 changes: 22 additions & 15 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,27 @@ interface OpikConfigFile {
workspace?: string;
project_name?: string;
is_local?: boolean;
provider?: string;
}

function parseOpikConfigFile(content: string): OpikConfigFile {
const config: OpikConfigFile = {};
const lines = content.split('\n');

for (const line of lines) {
const trimmed = line.trim();

// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('[')) {
continue;
}

// Parse key = value pairs
const match = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
if (match) {
const [, key, value] = match;
const cleanValue = value.trim();

switch (key) {
case 'api_key':
config.api_key = cleanValue;
Expand All @@ -46,30 +47,35 @@ function parseOpikConfigFile(content: string): OpikConfigFile {
case 'is_local':
config.is_local = cleanValue.toLowerCase() === 'true';
break;
case 'provider':
config.provider = cleanValue;
break;
}
}
}

return config;
}

export function getOpikConfig(): OpikConfig {
const logger = createLogger({ verbose: false });

// Try environment variables first
const apiKey = process.env.OPIK_API_KEY;
const baseUrl = process.env.OPIK_BASE_URL || 'http://localhost:5173';
const projectName = process.env.OPIK_PROJECT_NAME;
const workspace = process.env.OPIK_WORKSPACE;
const isLocal = process.env.OPIK_IS_LOCAL?.toLowerCase() === 'true';
const provider = process.env.OPIK_PROVIDER;

if (apiKey || isLocal) {
return {
api_key: apiKey,
base_url: baseUrl,
project_name: projectName || 'Claude Code',
workspace: workspace || 'default',
is_local: isLocal
is_local: isLocal,
provider,
};
}

Expand All @@ -80,7 +86,7 @@ export function getOpikConfig(): OpikConfig {
const configFile = readFileSync(configPath, 'utf8');
const config = parseOpikConfigFile(configFile);
logger.debug(`Parsed config:`, config);

if (!config.api_key && !config.is_local) {
throw new Error('No API key found in config file (required for cloud instance)');
}
Expand All @@ -90,14 +96,15 @@ export function getOpikConfig(): OpikConfig {
base_url: config.url_override || baseUrl,
project_name: config.project_name || 'Claude Code',
workspace: config.workspace || 'default',
is_local: config.is_local || false
is_local: config.is_local || false,
provider: config.provider,
};
logger.debug(`Using Opik config:`, {
...finalConfig,
api_key: finalConfig.api_key ? '***configured***' : 'not set'

logger.debug(`Using Opik config:`, {
...finalConfig,
api_key: finalConfig.api_key ? '***configured***' : 'not set'
});

return finalConfig;
} catch (error) {
logger.error(`Config error: ${error instanceof Error ? error.message : error}`);
Expand All @@ -109,4 +116,4 @@ export function getOpikConfig(): OpikConfig {

export function getClaudeDataDir(): string {
return process.env.CLAUDE_DATA_DIR || join(homedir(), '.claude');
}
}
Loading