sentry-client.ts•4.87 kB
import axios, { AxiosError } from "axios";
// Response type for Sentry API
export interface SentryIssue {
id: string;
title: string;
status: string;
level: string;
firstSeen: string;
lastSeen: string;
count: number;
stacktrace?: string;
}
export class SentryClient {
private readonly baseUrl = process.env.SENTRY_BASE_URL || "https://sentry.com/api/0";
private readonly authToken: string;
private readonly organizationSlug?: string;
private readonly projectSlug?: string;
constructor(authToken: string, organizationSlug?: string, projectSlug?: string) {
this.authToken = authToken;
this.organizationSlug = organizationSlug;
this.projectSlug = projectSlug;
}
/**
* Parse Sentry issue ID or URL
*/
private parseIssueIdOrUrl(issueIdOrUrl: string): { issueId: string; organizationSlug?: string; projectSlug?: string } {
// Check if it's a URL
if (issueIdOrUrl.startsWith("http")) {
try {
const url = new URL(issueIdOrUrl);
const pathParts = url.pathname.split("/").filter(part => part.length > 0);
// Try to extract organization, project, and issue ID from URL path
if (pathParts.length >= 4 && pathParts[0] === "organizations") {
return {
organizationSlug: pathParts[1],
projectSlug: pathParts[3],
issueId: pathParts[pathParts.length - 1]
};
}
// Some older Sentry URLs may have different formats
if (pathParts.length >= 3) {
return {
organizationSlug: pathParts[0],
projectSlug: pathParts[1],
issueId: pathParts[2]
};
}
} catch (error) {
// URL parsing failed, fallback to using original input as issue ID
}
}
// If not a URL or unable to parse URL, use input directly as issue ID
return {
issueId: issueIdOrUrl,
organizationSlug: this.organizationSlug,
projectSlug: this.projectSlug
};
}
/**
* Get Sentry issue details
*/
async getIssue(issueIdOrUrl: string): Promise<SentryIssue> {
const { issueId, organizationSlug, projectSlug } = this.parseIssueIdOrUrl(issueIdOrUrl);
if (!organizationSlug || !projectSlug) {
throw new Error(
"Organization slug and project slug are required. Provide them either in the constructor " +
"or as part of the issue URL."
);
}
try {
// Get basic issue information
const issueResponse = await axios.get(
`${this.baseUrl}/organizations/${organizationSlug}/issues/${issueId}/`,
{
headers: {
Authorization: `Bearer ${this.authToken}`,
"Content-Type": "application/json"
}
}
);
// Get the latest event to extract stack trace information
const eventsResponse = await axios.get(
`${this.baseUrl}/organizations/${organizationSlug}/issues/${issueId}/events/latest/`,
{
headers: {
Authorization: `Bearer ${this.authToken}`,
"Content-Type": "application/json"
}
}
);
// Extract stack trace
let stacktrace: string | undefined;
if (eventsResponse.data.entries) {
const exceptionEntry = eventsResponse.data.entries.find(
(entry: any) => entry.type === "exception"
);
if (exceptionEntry && exceptionEntry.data && exceptionEntry.data.values) {
const exceptions = exceptionEntry.data.values;
stacktrace = exceptions
.map((exception: any) => {
let frames = "";
if (exception.stacktrace && exception.stacktrace.frames) {
frames = exception.stacktrace.frames
.map((frame: any) => {
return ` at ${frame.function || "unknown"} (${frame.filename || "unknown"}:${frame.lineno || "?"}:${frame.colno || "?"})`;
})
.reverse()
.join("\n");
}
return `${exception.type}: ${exception.value}\n${frames}`;
})
.join("\n\nCaused by: ");
}
}
// Build and return issue details
return {
id: issueResponse.data.id,
title: issueResponse.data.title,
status: issueResponse.data.status,
level: issueResponse.data.level || "error",
firstSeen: issueResponse.data.firstSeen,
lastSeen: issueResponse.data.lastSeen,
count: issueResponse.data.count,
stacktrace
};
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response) {
throw new Error(`Sentry API error: ${error.response.status} - ${error.response.data.detail || JSON.stringify(error.response.data)}`);
}
throw error;
}
}
}