Skip to content
Merged
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
45 changes: 35 additions & 10 deletions packages/cli/generation/ir-generator/src/PackageTreeGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
WebSocketChannelId
} from "@fern-api/ir-sdk";
import { FilteredIr, getOriginalName, IdGenerator } from "@fern-api/ir-utils";
import { CliError } from "@fern-api/task-context";
import { mapValues } from "lodash-es";

type UnprocessedPackage = Omit<Package, "hasEndpointsInTree">;
Expand All @@ -40,7 +41,10 @@ export class PackageTreeGenerator {
public addPackageRedirection({ from, to }: { from: FernFilepath; to: FernFilepath }): void {
const package_ = this.getPackageForFernFilepath(from);
if (package_.navigationConfig != null) {
throw new Error("Found duplicate navigationConfig for package");
throw new CliError({
message: "Found duplicate navigationConfig for package",
code: CliError.Code.InternalError
});
}
package_.navigationConfig = {
pointsTo: IdGenerator.generateSubpackageId(to)
Expand All @@ -50,7 +54,7 @@ export class PackageTreeGenerator {
public addDocs(fernFilepath: FernFilepath, docs: string): void {
const package_ = this.getPackageForFernFilepath(fernFilepath);
if (package_.docs != null) {
throw new Error("Found duplicate docs for package");
throw new CliError({ message: "Found duplicate docs for package", code: CliError.Code.InternalError });
}
package_.docs = docs;
}
Expand All @@ -70,23 +74,32 @@ export class PackageTreeGenerator {
public addService(serviceId: ServiceId, service: HttpService): void {
const package_ = this.getPackageForFernFilepath(service.name.fernFilepath);
if (package_.service != null) {
throw new Error("Found duplicate service for " + serviceId);
throw new CliError({
message: "Found duplicate service for " + serviceId,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 User API definition metadata now silently exfiltrated to Sentry via InternalError/ResolutionError reclassification

Before this PR, these throws were plain Error objects. resolveErrorCode() in CliError.ts resolves unknown errors to Unclassified, for which shouldReportToSentry() returns false. After this PR they are CliError with InternalError or ResolutionError codes, both of which have shouldReportToSentry() = true. The full error (including its .message) is then sent to Sentry via captureException in the catch block inside runInteractiveTask.

Error messages that now reach Sentry include user-supplied API definition identifiers: serviceId, webhookGroupId, websocketChannelId, subpackageId (this file), plus JSON.stringify(endpointDeclaration) in convertTransport.ts and type/wire-key names in several other migrated files. The SENTRY_DSN is baked into the production CLI binary at publish time (see .github/workflows/publish-cli.yml), so this affects all users who have not set FERN_DISABLE_TELEMETRY=true.

Concrete impact: proprietary API definition names and structure are sent to Fern's Sentry instance whenever these error paths are hit, which was not the case before this PR.

Prompt To Fix With AI
For any CliError whose message embeds user-supplied values (serviceId, webhookGroupId, subpackageId, JSON.stringify(endpointDeclaration), wireKey, typeId, etc.) that is assigned an InternalError or ResolutionError code (both of which are Sentry-reportable), either:
1. Strip the dynamic user data from the message and move it to a separate Sentry 'extra' context field that is only attached at the captureException call site, OR
2. Change the code to ValidationError / ConfigError / ReferenceError for those specific call sites where the error is triggered by user configuration rather than a true programming bug (those codes are not Sentry-reported), OR
3. Add a Sentry beforeSend hook in SentryClient that scrubs or truncates error messages before transmission.

Files affected: PackageTreeGenerator.ts (serviceId, webhookGroupId, websocketChannelId, subpackageId), convertTransport.ts (JSON.stringify(endpointDeclaration), source.proto, relativeFilePath), convertExampleType.ts (discriminant, rawValueType, typeBeingExemplified), DynamicSnippetsConverter.ts (endpoint.id, typeId), validateObjectExample.ts (wireKey), validateTypeReferenceExample.ts, and others migrated in this PR.

Severity: low | Confidence: 78%

code: CliError.Code.InternalError
});
}
package_.service = serviceId;
}

public addWebhookGroup(webhookGroupId: WebhookGroupId, fernFilepath: FernFilepath): void {
const package_ = this.getPackageForFernFilepath(fernFilepath);
if (package_.webhooks != null) {
throw new Error("Found duplicate webhook group for " + webhookGroupId);
throw new CliError({
message: "Found duplicate webhook group for " + webhookGroupId,
code: CliError.Code.InternalError
});
}
package_.webhooks = webhookGroupId;
}

public addWebSocketChannel(websocketChannelId: WebSocketChannelId, fernFilepath: FernFilepath): void {
const package_ = this.getPackageForFernFilepath(fernFilepath);
if (package_.webhooks != null) {
throw new Error("Found duplicate webhook group for " + websocketChannelId);
throw new CliError({
message: "Found duplicate webhook group for " + websocketChannelId,
code: CliError.Code.InternalError
});
Comment on lines +88 to +102
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The addWebSocketChannel method checks package_.webhooks instead of package_.websocket for duplicates, and the error message incorrectly says "webhook group" instead of "websocket channel". This will fail to detect duplicate websocket channels and may incorrectly throw an error when a webhook group exists.

// Should be:
if (package_.websocket != null) {
    throw new CliError({
        message: "Found duplicate websocket channel for " + websocketChannelId,
        code: CliError.Code.InternalError
    });
}

Note: This is a pre-existing bug in the original code (line 88-89), but the PR changes the error on lines 99-102 without fixing it.

Suggested change
throw new CliError({
message: "Found duplicate webhook group for " + webhookGroupId,
code: CliError.Code.InternalError
});
}
package_.webhooks = webhookGroupId;
}
public addWebSocketChannel(websocketChannelId: WebSocketChannelId, fernFilepath: FernFilepath): void {
const package_ = this.getPackageForFernFilepath(fernFilepath);
if (package_.webhooks != null) {
throw new Error("Found duplicate webhook group for " + websocketChannelId);
throw new CliError({
message: "Found duplicate webhook group for " + websocketChannelId,
code: CliError.Code.InternalError
});
throw new CliError({
message: "Found duplicate webhook group for " + webhookGroupId,
code: CliError.Code.InternalError
});
}
package_.webhooks = webhookGroupId;
}
public addWebSocketChannel(websocketChannelId: WebSocketChannelId, fernFilepath: FernFilepath): void {
const package_ = this.getPackageForFernFilepath(fernFilepath);
if (package_.websocket != null) {
throw new CliError({
message: "Found duplicate websocket channel for " + websocketChannelId,
code: CliError.Code.InternalError
});

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

}
package_.websocket = websocketChannelId;
}
Expand Down Expand Up @@ -152,26 +165,35 @@ export class PackageTreeGenerator {

public sortRootPackage(subpackagesInOrder: SubpackageId[]): void {
if (!isEqualIgnoreOrder(this.rootPackage.subpackages, subpackagesInOrder)) {
throw new Error("Sorted subpackages differ from unsorted packages in root");
throw new CliError({
message: "Sorted subpackages differ from unsorted packages in root",
code: CliError.Code.InternalError
});
}
this.rootPackage.subpackages = subpackagesInOrder;
}

public sortSubpackage(subpackageId: SubpackageId, subpackagesInOrder: SubpackageId[]): void {
const subpackage = this.subpackages[subpackageId];
if (subpackage == null) {
throw new Error("Subpackage does not exist: " + subpackageId);
throw new CliError({
message: "Subpackage does not exist: " + subpackageId,
code: CliError.Code.ResolutionError
});
}
if (!isEqualIgnoreOrder(subpackage.subpackages, subpackagesInOrder)) {
throw new Error("Sorted subpackages differ from unsorted packages");
throw new CliError({
message: "Sorted subpackages differ from unsorted packages",
code: CliError.Code.InternalError
});
}
subpackage.subpackages = subpackagesInOrder;
}

private getAllSubpackagesWithEndpoints(root: SubpackageId): SubpackageId[] {
const subpackage = this.subpackages[root];
if (subpackage == null) {
throw new Error("Subpackage does not exist: " + root);
throw new CliError({ message: "Subpackage does not exist: " + root, code: CliError.Code.ResolutionError });
}

const subpackagesWithEndpoints = this.getAllChildrenWithEndpoints(subpackage);
Expand Down Expand Up @@ -200,7 +222,10 @@ export class PackageTreeGenerator {
const subpackagesInParent = parent.subpackages.map((subpackageId) => {
const subpackage = this.subpackages[subpackageId];
if (subpackage == null) {
throw new Error("Subpackage ID is invalid: " + subpackageId);
throw new CliError({
message: "Subpackage ID is invalid: " + subpackageId,
code: CliError.Code.InternalError
});
}
return subpackage;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
InferredAuthSchemeTokenEndpoint,
OAuthConfiguration
} from "@fern-api/ir-sdk";
import { CliError } from "@fern-api/task-context";

import { FernFileContext } from "../FernFileContext.js";
import { EndpointResolver } from "../resolvers/EndpointResolver.js";
Expand Down Expand Up @@ -105,7 +106,7 @@ function convertSchemeReference({
const convertNamedAuthSchemeReference = (reference: string, docs: string | undefined) => {
const declaration = authSchemeDeclarations?.[reference];
if (declaration == null) {
throw new Error("Unknown auth scheme: " + reference);
throw new CliError({ message: "Unknown auth scheme: " + reference, code: CliError.Code.ReferenceError });
}
return visitRawAuthSchemeDeclaration<AuthScheme>(declaration, {
header: (rawHeader) =>
Expand Down Expand Up @@ -265,7 +266,10 @@ function generateOAuth({
)
});
default:
throw new Error(`Unknown OAuth type: '${rawScheme?.type}'`);
throw new CliError({
message: `Unknown OAuth type: '${rawScheme?.type}'`,
code: CliError.Code.ValidationError
});
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
WebSocketMessageBody
} from "@fern-api/ir-sdk";
import { constructHttpPath, getOriginalName } from "@fern-api/ir-utils";
import { CliError } from "@fern-api/task-context";
import { FernFileContext } from "../FernFileContext.js";
import { getHeaderName } from "../index.js";
import { ExampleResolver } from "../resolvers/ExampleResolver.js";
Expand Down Expand Up @@ -135,7 +136,10 @@ export function convertChannel({
? Object.entries(example["query-parameters"]).map(([wireKey, value]) => {
const queryParameterDeclaration = channel["query-parameters"]?.[wireKey];
if (queryParameterDeclaration == null) {
throw new Error(`Query parameter ${wireKey} does not exist`);
throw new CliError({
message: `Query parameter ${wireKey} does not exist`,
code: CliError.Code.ReferenceError
});
}
return {
name: file.casingsGenerator.generateNameAndWireValue({
Expand Down Expand Up @@ -164,7 +168,10 @@ export function convertChannel({
messages: example.messages.map((messageExample): ExampleWebSocketMessage => {
const message = channel.messages?.[messageExample.type];
if (message == null) {
throw new Error(`Message ${messageExample.type} does not exist`);
throw new CliError({
message: `Message ${messageExample.type} does not exist`,
code: CliError.Code.ReferenceError
});
}
return {
type: messageExample.type,
Expand Down Expand Up @@ -225,7 +232,7 @@ function convertExampleWebSocketMessageBody({
}

if (!isPlainObject(example)) {
throw new Error("Example must be an object");
throw new CliError({ message: "Example must be an object", code: CliError.Code.ValidationError });
}

const exampleProperties: ExampleInlinedRequestBodyProperty[] = [];
Expand Down Expand Up @@ -259,7 +266,10 @@ function convertExampleWebSocketMessageBody({
file
});
if (originalTypeDeclaration == null) {
throw new Error("Could not find original type declaration for property: " + wireKey);
throw new CliError({
message: "Could not find original type declaration for property: " + wireKey,
code: CliError.Code.ResolutionError
});
}
exampleProperties.push({
name: file.casingsGenerator.generateNameAndWireValue({
Expand Down Expand Up @@ -391,7 +401,10 @@ function convertChannelPathParameters({
})
);
} else {
throw new Error(`Path parameter ${key} does not exist`);
throw new CliError({
message: `Path parameter ${key} does not exist`,
code: CliError.Code.ReferenceError
});
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RawSchemas } from "@fern-api/fern-definition-schema";
import { OAuthClientCredentials } from "@fern-api/ir-sdk";

import { CliError } from "@fern-api/task-context";
import { FernFileContext } from "../FernFileContext.js";
import { EndpointResolver } from "../resolvers/EndpointResolver.js";
import { PropertyResolver } from "../resolvers/PropertyResolver.js";
Expand Down Expand Up @@ -34,7 +34,10 @@ export function convertOAuthClientCredentials({
tokenEndpoint
});
if (oauthTokenEndpoint == null) {
throw new Error("Failed to convert OAuth token endpoint.");
throw new CliError({
message: "Failed to convert OAuth token endpoint.",
code: CliError.Code.IrConversionError
});
}
const refreshEndpoint =
refreshTokenEndpoint != null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { generatorsYml } from "@fern-api/configuration";
import { HttpEndpoint, HttpService, ReadmeConfig, ServiceId } from "@fern-api/ir-sdk";
import { CliError } from "@fern-api/task-context";
import urlJoin from "url-join";

export function convertReadmeConfig({
Expand Down Expand Up @@ -58,7 +59,10 @@ class ReadmeEndpointCache {
public getEndpointForReadmeOrThrow(readmeEndpoint: generatorsYml.ReadmeEndpointSchema): HttpEndpoint {
const endpoint = this.getEndpointForReadme(readmeEndpoint);
if (endpoint == null) {
throw new Error(`Endpoint not found for ${JSON.stringify(readmeEndpoint)}`);
throw new CliError({
message: `Endpoint not found for ${JSON.stringify(readmeEndpoint)}`,
code: CliError.Code.ResolutionError
});
}
return endpoint;
}
Expand Down Expand Up @@ -117,7 +121,7 @@ function getReadmeEndpointObject({
if (typeof endpoint === "string") {
const split = endpoint.split(" ");
if (split.length !== 2 || split[0] == null || split[1] == null) {
throw new Error(`invalid endpoint string: ${endpoint}`);
throw new CliError({ message: `invalid endpoint string: ${endpoint}`, code: CliError.Code.ParseError });
}
return {
method: split[0],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
WebhookTimestampFormat
} from "@fern-api/ir-sdk";
import { IdGenerator, isReferencedWebhookPayloadSchema } from "@fern-api/ir-utils";

import { CliError } from "@fern-api/task-context";
import { FernFileContext } from "../FernFileContext.js";
import { ExampleResolver } from "../resolvers/ExampleResolver.js";
import { TypeResolver } from "../resolvers/TypeResolver.js";
Expand Down Expand Up @@ -200,7 +200,10 @@ function convertWebhookExamples({
}));
}
if (!isPlainObject(webhook.payload)) {
throw new Error(`Example webhook payload is not an object. Got: ${JSON.stringify(webhook.payload)}`);
throw new CliError({
message: `Example webhook payload is not an object. Got: ${JSON.stringify(webhook.payload)}`,
code: CliError.Code.ValidationError
});
}
// The payload example is a simple object of key, value pairs, so we format the example as
// a map<string, unknown> for simplicity. If we ever add support for webhooks in the generated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import {
NameOrString
} from "@fern-api/ir-sdk";
import { getOriginalName, hashJSON } from "@fern-api/ir-utils";
import { CliError } from "@fern-api/task-context";
import urlJoin from "url-join";

import { FernFileContext } from "../../FernFileContext.js";
import { ErrorResolver } from "../../resolvers/ErrorResolver.js";
import { ExampleResolver } from "../../resolvers/ExampleResolver.js";
Expand Down Expand Up @@ -89,7 +89,10 @@ export function convertExampleEndpointCall({
? endpoint.request?.["query-parameters"]?.[wireKey]
: undefined;
if (queryParameterDeclaration == null) {
throw new Error(`Query parameter ${wireKey} does not exist`);
throw new CliError({
message: `Query parameter ${wireKey} does not exist`,
code: CliError.Code.ReferenceError
});
}
const isAllowMultiple =
typeof queryParameterDeclaration !== "string" &&
Expand Down Expand Up @@ -252,7 +255,10 @@ function convertPathParameters({
})
);
} else {
throw new Error(`Path parameter ${key} does not exist`);
throw new CliError({
message: `Path parameter ${key} does not exist`,
code: CliError.Code.ReferenceError
});
}
}
}
Expand Down Expand Up @@ -405,7 +411,10 @@ function convertExampleRequestBody({
}

if (!isPlainObject(example.request)) {
throw new Error(`Example is not an object. Got: ${JSON.stringify(example.request)}`);
throw new CliError({
message: `Example is not an object. Got: ${JSON.stringify(example.request)}`,
code: CliError.Code.ValidationError
});
}

const exampleProperties: ExampleInlinedRequestBodyProperty[] = [];
Expand Down Expand Up @@ -463,7 +472,10 @@ function convertExampleRequestBody({
});
continue;
}
throw new Error("Could not find original type declaration for property: " + wireKey);
throw new CliError({
message: "Could not find original type declaration for property: " + wireKey,
code: CliError.Code.ResolutionError
});
}
exampleProperties.push({
name: file.casingsGenerator.generateNameAndWireValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
HttpRequestBodyReference,
InlinedRequestBodyProperty
} from "@fern-api/ir-sdk";

import { CliError } from "@fern-api/task-context";
import { FernFileContext } from "../../FernFileContext.js";
import { parseTypeName } from "../../utils/parseTypeName.js";
import { convertAvailability } from "../convertDeclaration.js";
Expand Down Expand Up @@ -102,7 +102,7 @@ export function convertHttpRequestBody({

if (isInlineRequestBody(request.body)) {
if (request.name == null) {
throw new Error("Name is missing for inlined request");
throw new CliError({ message: "Name is missing for inlined request", code: CliError.Code.InternalError });
}

return HttpRequestBody.inlinedRequestBody({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isInlineRequestBody, parseRawBytesType, RawSchemas } from "@fern-api/fern-definition-schema";
import { SdkRequest, SdkRequestBodyType, SdkRequestShape } from "@fern-api/ir-sdk";
import { CliError } from "@fern-api/task-context";
import { size } from "lodash-es";

import { FernFileContext } from "../../FernFileContext.js";
import { PropertyResolver } from "../../resolvers/PropertyResolver.js";
import { TypeResolver } from "../../resolvers/TypeResolver.js";
Expand Down Expand Up @@ -59,7 +59,7 @@ function convertHttpSdkRequestShape({
}): SdkRequestShape | undefined {
const constructWrapper = () => {
if (typeof request === "string" || request?.name == null) {
throw new Error("Name is missing for request wrapper");
throw new CliError({ message: "Name is missing for request wrapper", code: CliError.Code.InternalError });
}
return SdkRequestShape.wrapper({
wrapperName: file.casingsGenerator.generateName(request.name),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "@fern-api/ir-sdk";
import { constructHttpPath, IdGenerator } from "@fern-api/ir-utils";
import { SourceResolver } from "@fern-api/source-resolver";
import { CliError } from "@fern-api/task-context";
import urlJoin from "url-join";
import { FernFileContext } from "../../FernFileContext.js";
import { ErrorResolver } from "../../resolvers/ErrorResolver.js";
Expand Down Expand Up @@ -372,7 +373,7 @@ export function resolvePathParameterOrThrow({
file
});
if (resolved == null) {
throw new Error("Cannot resolve path parameter");
throw new CliError({ message: "Cannot resolve path parameter", code: CliError.Code.InternalError });
}
return resolved;
}
Expand Down
Loading