All files / src/lib/dsn parser.ts

97.29% Statements 36/37
95.23% Branches 20/21
100% Functions 9/9
97.29% Lines 36/37

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235                                            164x         164x                           1391x 1391x                                       1411x 1411x     1411x     1411x 1411x 27x       1182x 1182x         1182x 1182x 1182x 14x       1168x   1411x                 202x                     150x                                     854x 854x 51x     803x                                         177x 177x 177x     177x         59x     118x                                                             481x 985x     985x 985x         481x                                             73x    
/**
 * DSN Parser
 *
 * Parses Sentry DSN strings to extract organization and project identifiers.
 *
 * DSN Format: {PROTOCOL}://{PUBLIC_KEY}@{HOST}/{PROJECT_ID}
 * Example: https://abc123@o1169445.ingest.us.sentry.io/4505229541441536
 *
 * For SaaS DSNs, the host contains the org ID in the pattern: oXXX.ingest...
 */
 
import {
  type DetectedDsn,
  type DsnSource,
  MONOREPO_ROOTS,
  type ParsedDsn,
} from "./types.js";
 
/**
 * Regular expression to match org ID from Sentry SaaS ingest hosts
 * Matches patterns like: o1169445.ingest.sentry.io or o1169445.ingest.us.sentry.io
 */
const ORG_ID_PATTERN = /^o(\d+)\.ingest(?:\.[a-z]+)?\.sentry\.io$/;
 
/**
 * Pattern to strip trailing colon from protocol
 */
const PROTOCOL_COLON_PATTERN = /:$/;
 
/**
 * Extract organization ID from a Sentry ingest host
 *
 * @param host - The host portion of the DSN (e.g., "o1169445.ingest.us.sentry.io")
 * @returns The numeric org ID as a string, or null if not a SaaS ingest host
 *
 * @example
 * extractOrgIdFromHost("o1169445.ingest.us.sentry.io") // "1169445"
 * extractOrgIdFromHost("o123.ingest.sentry.io") // "123"
 * extractOrgIdFromHost("sentry.mycompany.com") // null (self-hosted)
 */
export function extractOrgIdFromHost(host: string): string | null {
  const match = host.match(ORG_ID_PATTERN);
  return match?.[1] ?? null;
}
 
/**
 * Parse a Sentry DSN string into its components
 *
 * @param dsn - The full DSN string
 * @returns Parsed DSN components, or null if invalid
 *
 * @example
 * parseDsn("https://abc123@o1169445.ingest.us.sentry.io/4505229541441536")
 * // {
 * //   protocol: "https",
 * //   publicKey: "abc123",
 * //   host: "o1169445.ingest.us.sentry.io",
 * //   projectId: "4505229541441536",
 * //   orgId: "1169445"
 * // }
 */
export function parseDsn(dsn: string): ParsedDsn | null {
  try {
    const url = new URL(dsn);
 
    // Protocol without the trailing colon
    const protocol = url.protocol.replace(PROTOCOL_COLON_PATTERN, "");
 
    // Public key is the username portion
    const publicKey = url.username;
    if (!publicKey) {
      return null;
    }
 
    // Host
    const host = url.host;
    Iif (!host) {
      return null;
    }
 
    // Project ID is the last path segment
    const pathParts = url.pathname.split("/").filter(Boolean);
    const projectId = pathParts.at(-1);
    if (!projectId) {
      return null;
    }
 
    // Try to extract org ID from host (SaaS only)
    const orgId = extractOrgIdFromHost(host) ?? undefined;
 
    return {
      protocol,
      publicKey,
      host,
      projectId,
      orgId,
    };
  } catch {
    // Invalid URL
    return null;
  }
}
 
/**
 * Validate that a string looks like a Sentry DSN
 *
 * @param value - String to validate
 * @returns True if the string appears to be a valid DSN
 */
export function isValidDsn(value: string): boolean {
  return parseDsn(value) !== null;
}
 
/**
 * Create a DetectedDsn from a raw DSN string.
 * Parses the DSN and attaches source metadata.
 *
 * @param raw - Raw DSN string
 * @param source - Where the DSN was detected from
 * @param sourcePath - Relative path to source file (for file-based sources)
 * @param packagePath - Package/app directory for monorepo grouping (e.g., "packages/frontend")
 * @returns DetectedDsn with parsed components, or null if DSN is invalid
 */
export function createDetectedDsn(
  raw: string,
  source: DsnSource,
  sourcePath?: string,
  packagePath?: string
): DetectedDsn | null {
  const parsed = parseDsn(raw);
  if (!parsed) {
    return null;
  }
 
  return {
    ...parsed,
    raw,
    source,
    sourcePath,
    packagePath,
  };
}
 
/**
 * Infer package path from a source file path.
 *
 * Detects common monorepo patterns like:
 * - packages/frontend/src/index.ts → "packages/frontend"
 * - apps/web/.env → "apps/web"
 * - src/index.ts → undefined (root project)
 *
 * @param sourcePath - Relative path to source file
 * @returns Package path or undefined if at root
 */
export function inferPackagePath(sourcePath: string): string | undefined {
  const parts = sourcePath.split("/");
  const root = parts[0];
  const pkg = parts[1];
 
  // Check if path starts with a common monorepo directory pattern
  if (
    root &&
    pkg &&
    MONOREPO_ROOTS.includes(root as (typeof MONOREPO_ROOTS)[number])
  ) {
    return `${root}/${pkg}`;
  }
 
  return;
}
 
/**
 * Create a fingerprint from detected DSNs for cache validation.
 *
 * The fingerprint uniquely identifies the set of projects detected in a workspace.
 * Aliases cached with one fingerprint are only valid when the same DSNs are detected.
 *
 * For DSNs with orgId (SaaS pattern): uses "orgId:projectId"
 * For DSNs without orgId (self-hosted or non-standard): uses "host:projectId"
 *
 * @param dsns - Array of detected DSNs
 * @returns Fingerprint string (sorted comma-separated identifier pairs)
 *
 * @example
 * // SaaS DSNs with orgId
 * createDsnFingerprint([
 *   { orgId: "123", projectId: "456", host: "o123.ingest.sentry.io", ... },
 *   { orgId: "123", projectId: "789", host: "o123.ingest.sentry.io", ... }
 * ])
 * // Returns: "123:456,123:789"
 *
 * @example
 * // Self-hosted DSN without orgId
 * createDsnFingerprint([
 *   { projectId: "1", host: "sentry.mycompany.com", ... }
 * ])
 * // Returns: "sentry.mycompany.com:1"
 */
export function createDsnFingerprint(dsns: DetectedDsn[]): string {
  const keys = dsns
    .filter((d) => d.projectId)
    .map((d) => {
      // Use orgId if available (SaaS pattern), otherwise use host (self-hosted)
      const prefix = d.orgId ?? d.host;
      return `${prefix}:${d.projectId}`;
    })
    .sort();
 
  // Deduplicate (same DSN might be detected from multiple sources)
  return [...new Set(keys)].join(",");
}
 
/**
 * Normalize a DSN-style org identifier to a numeric org ID.
 *
 * DSN hosts encode org IDs as `oNNNNN` (e.g., `o1081365` from
 * `o1081365.ingest.us.sentry.io`). The Sentry API accepts numeric IDs
 * via `organization_id_or_slug` but not the `o`-prefixed DSN form.
 *
 * Reuses `extractOrgIdFromHost` by constructing a synthetic ingest hostname
 * from the bare identifier, sharing the same pattern logic.
 *
 * @param org - Raw org identifier (e.g. `"o1081365"` or `"my-org"`)
 * @returns Numeric org ID if input matches `oNNNNN`, otherwise unchanged
 *
 * @example
 * stripDsnOrgPrefix("o1081365")  // "1081365"
 * stripDsnOrgPrefix("o123")      // "123"
 * stripDsnOrgPrefix("sentry")    // "sentry"  (no change)
 * stripDsnOrgPrefix("organic")   // "organic" (no change — not all digits after 'o')
 */
export function stripDsnOrgPrefix(org: string): string {
  return extractOrgIdFromHost(`${org}.ingest.sentry.io`) ?? org;
}