Repository: confluentinc/vscode
Pull request #1311: chore: move Sentry init logic, manually setup client
Author: login: noeldevelops
Created: 2025-03-20T21:18:30Z | Merged: 2025-03-26T21:37:37Z

## Summary of Changes

<!-- Include a high-level overview of your implementation, including any alternatives you considered and items you'll address in follow-up PRs -->

- Change the way we setup Sentry to manually create the NodeClient and Scope (instead of using the node package's managed `init` function), giving us full control over the exceptions captured and removing the possibility that 3rd party errors will make it in our Sentry account. 
- Move any calls to `Sentry.captureException()` to either call the new method, or use the new-ish `logError()` function, which was updated with the new capture method as well. 
- Closes #982  <-- see issue for more info

## Any additional details or context that should be provided?

<!-- Behavior before/after, more technical details/screenshots, follow-on work that should be expected, links to discussions or issues, etc -->

- Made it similar to the `telemetryLogger.ts` pattern where Sentry Scope and Client will be initialized only once. 
- ⚠️ We will need to be extra diligent about catching & sending any exceptions to Sentry in the code, since this setup removes the global handlers (e.g. "OnUnhandledRejection", "OnUncaughtException") 

## Pull request checklist

Please check if your PR fulfills the following (if applicable):

##### Tests

- [ ] Added new
- [ ] Updated existing
- [ ] Deleted existing

##### Other

- [ ] All new disposables (event listeners, views, channels, etc.) collected as  for eventual cleanup?
<!-- prettier-ignore -->
- [ ] Does anything in this PR need to be mentioned in the user-facing [CHANGELOG](https://github.com/confluentinc/vscode/blob/main/CHANGELOG.md) or [README](https://github.com/confluentinc/vscode/blob/main/public/README.md)?
- [ ] Have you validated this change locally by [packaging](https://github.com/confluentinc/vscode/blob/main/README.md#packaging-steps) and installing the extension `.vsix` file?
  ```shell
  gulp clicktest
  ```


Linked issue #982: Change Sentry init to avoid polluting global scope
While "rubber ducking" about Sentry 3rd party errors showing up as a team, we (re?)discovered that Sentry [recommends](https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/#shared-environment-setup) we initialize our client in a custom way. Let's do it! 

See Node client example here: https://github.com/getsentry/sentry-javascript/blob/a27652f09538e91d3c6c23f8eeadb75743067428/packages/node/src/sdk/index.ts#L104

Might need some discussion and further investigation to make sure we continue to catch all Unhandled exceptions with Sentry 🤔 

Diff:
diff --git a/Gulpfile.js b/Gulpfile.js
index 3ea25ecb52..024a97f97b 100644
--- a/Gulpfile.js
+++ b/Gulpfile.js
@@ -134,7 +134,6 @@ export function build(done) {
         project: "vscode-extension",
         release: { name: process.env.SENTRY_RELEASE },
         disable: !process.env.SENTRY_AUTH_TOKEN,
-        applicationKey: "confluent-vscode-extension-sentry-do-not-use",
       }),
     ],
     onLog: handleBuildLog,
diff --git a/src/commands/docker.ts b/src/commands/docker.ts
index 522a72f908..0ed224cfe9 100644
--- a/src/commands/docker.ts
+++ b/src/commands/docker.ts
@@ -1,4 +1,3 @@
-import * as Sentry from "@sentry/node";
 import {
   CancellationToken,
   Disposable,
@@ -19,6 +18,7 @@ import { ConnectionLabel } from "../models/resource";
 import { LOCAL_DOCKER_SOCKET_PATH } from "../preferences/constants";
 import { localResourcesQuickPick } from "../quickpicks/localResources";
 import { UserEvent } from "../telemetry/events";
+import { sentryCaptureException } from "../telemetry/sentryClient";
 
 const logger = new Logger("commands.docker");
 
@@ -137,11 +137,13 @@ export async function runWorkflowWithProgress(
               status: "workflow failed",
               start,
             });
-            Sentry.captureException(error, {
-              tags: {
-                dockerImage: workflow.imageRepoTag,
-                extensionUserFlow: "Local Resource Management",
-                localResourceKind: workflow.resourceKind,
+            sentryCaptureException(error, {
+              captureContext: {
+                tags: {
+                  dockerImage: workflow.imageRepoTag,
+                  extensionUserFlow: "Local Resource Management",
+                  localResourceKind: workflow.resourceKind,
+                },
               },
             });
             let errorMsg: string = "";
diff --git a/src/commands/topics.ts b/src/commands/topics.ts
index 94faac0a1b..2aa3f455c0 100644
--- a/src/commands/topics.ts
+++ b/src/commands/topics.ts
@@ -1,4 +1,3 @@
-import * as Sentry from "@sentry/node";
 import * as vscode from "vscode";
 import { registerCommandWithLogging } from ".";
 import {
@@ -19,6 +18,7 @@ import { MESSAGE_URI_SCHEME } from "../documentProviders/message";
 import {
   DEFAULT_ERROR_NOTIFICATION_BUTTONS,
   isResponseError,
+  logError,
   showErrorNotificationWithButtons,
 } from "../errors";
 import { Logger } from "../logging";
@@ -104,8 +104,7 @@ async function editTopicConfig(topic: KafkaTopic): Promise<void> {
       topic_name: topic.name,
     });
   } catch (err) {
-    logger.error("Failed to retrieve topic configs list", err);
-    Sentry.captureException(err);
+    logError(err, "list topic configs", {}, true);
     vscode.window.showErrorMessage("Failed to retrieve topic configs");
     return;
   }
@@ -153,8 +152,7 @@ async function editTopicConfig(topic: KafkaTopic): Promise<void> {
         const errorBody = await err.response.json();
         formError = errorBody.message;
       } else {
-        logger.error("Failed to update topic config", err);
-        Sentry.captureException(err);
+        logError(err, "update topic config", {}, true);
         if (err instanceof Error && err.message) formError = err.message;
       }
       return { success: false, message: formError };
diff --git a/src/consume.ts b/src/consume.ts
index 6c432f2534..fa031b1846 100644
--- a/src/consume.ts
+++ b/src/consume.ts
@@ -1,4 +1,3 @@
-import * as Sentry from "@sentry/node";
 import { utcTicks } from "d3-time";
 import { Data } from "dataclass";
 import { ObservableScope } from "inertial";
@@ -46,6 +45,7 @@ import { WebviewPanelCache } from "./webview-cache";
 import { handleWebviewMessage } from "./webview/comms/comms";
 import { type post } from "./webview/message-viewer";
 import messageViewerTemplate from "./webview/message-viewer.html";
+import { logError, showErrorNotificationWithButtons } from "./errors";
 
 const logger = new Logger("consume");
 
@@ -544,14 +544,8 @@ function messageViewerStartPollingCommand(
             }
             default: {
               reportable = { message: "Something went wrong." };
-              Sentry.captureException(error, { extra: { status, payload } });
-              window
-                .showErrorMessage("Error response while consuming messages.", "Open Logs")
-                .then((action) => {
-                  if (action === "Open Logs") {
-                    commands.executeCommand("confluent.showSidecarOutputChannel");
-                  }
-                });
+              logError(error, "message viewer", { status: status.toString(), payload }, true);
+              showErrorNotificationWithButtons("Error response while consuming messages.");
               break;
             }
           }
diff --git a/src/errors.ts b/src/errors.ts
index 42651c7720..c07f0ec207 100644
--- a/src/errors.ts
+++ b/src/errors.ts
@@ -1,5 +1,3 @@
-import * as Sentry from "@sentry/node";
-
 import { commands, window } from "vscode";
 import { ResponseError as DockerResponseError } from "./clients/docker";
 import { ResponseError as KafkaResponseError } from "./clients/kafkaRest";
@@ -8,6 +6,7 @@ import { ResponseError as SchemaRegistryResponseError } from "./clients/schemaRe
 import { ResponseError as SidecarResponseError } from "./clients/sidecar";
 import { Logger } from "./logging";
 import { logUsage, UserEvent } from "./telemetry/events";
+import { sentryCaptureException } from "./telemetry/sentryClient";
 
 const logger = new Logger("errors");
 
@@ -123,10 +122,13 @@ export async function logError(
   }
 
   logger.error(errorMessage, { ...errorContext, ...extra });
+  // TODO: follow up to reuse EventHint type for capturing tags and other more fine-grained data
   if (sendTelemetry) {
-    Sentry.captureException(e, {
-      contexts: { response: { status_code: responseStatusCode } },
-      extra: { ...errorContext, ...extra },
+    sentryCaptureException(e, {
+      captureContext: {
+        contexts: { response: { status_code: responseStatusCode } },
+        extra: { ...errorContext, ...extra },
+      },
     });
   }
 }
diff --git a/src/extension.ts b/src/extension.ts
index adebfffb9e..79880d6a75 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -1,47 +1,10 @@
-// Import this first!
-import * as SentryCore from "@sentry/core";
-import * as Sentry from "@sentry/node";
-/**
- * Initialize Sentry for error tracking (and future performance monitoring?).
- * Sentry.init needs to be run first before any other code so that Sentry can capture all errors.
- * `process.env.SENTRY_DSN` is fetched & defined during production builds only for Confluent official release process
- *
- * @see https://docs.sentry.io/platforms/node/
- */
-if (process.env.SENTRY_DSN) {
-  Sentry.init({
-    // debug: true, // enable for local "prod" debugging with dev console
-    dsn: process.env.SENTRY_DSN,
-    environment: process.env.SENTRY_ENV,
-    release: process.env.SENTRY_RELEASE,
-    tracesSampleRate: 0,
-    profilesSampleRate: 0,
-    integrations: [
-      // https://docs.sentry.io/platforms/javascript/configuration/filtering/#using-thirdpartyerrorfilterintegration
-      SentryCore.thirdPartyErrorFilterIntegration({
-        filterKeys: ["confluent-vscode-extension-sentry-do-not-use"],
-        behaviour: "drop-error-if-exclusively-contains-third-party-frames",
-      }),
-      Sentry.rewriteFramesIntegration(),
-    ],
-    ignoreErrors: [
-      "The request failed and the interceptors did not return an alternative response",
-      "ENOENT: no such file or directory",
-      "EPERM: operation not permitted",
-      "Canceled",
-      // rejected promises from the CCloud auth provider that can't be wrapped in try/catch:
-      "User cancelled the authentication flow.",
-      "User reset their password.",
-      "Confluent Cloud authentication failed. See browser for details.",
-    ],
-  });
-}
-
 import * as vscode from "vscode";
-import { checkTelemetrySettings, includeObservabilityContext } from "./telemetry/eventProcessors";
+/** First things first, setup Sentry to catch errors during activation and beyond
+ * `process.env.SENTRY_DSN` is fetched & defined during production builds only for Confluent official release process
+ * */
+import { closeSentryClient, initSentry } from "./telemetry/sentryClient";
 if (process.env.SENTRY_DSN) {
-  Sentry.addEventProcessor(checkTelemetrySettings);
-  Sentry.addEventProcessor(includeObservabilityContext);
+  initSentry();
 }
 
 import { ConfluentCloudAuthProvider, getAuthProvider } from "./authn/ccloudProvider";
@@ -100,6 +63,7 @@ import { SchemasViewProvider } from "./viewProviders/schemas";
 import { SEARCH_DECORATION_PROVIDER } from "./viewProviders/search";
 import { SupportViewProvider } from "./viewProviders/support";
 import { TopicViewProvider } from "./viewProviders/topics";
+import { sentryCaptureException } from "./telemetry/sentryClient";
 
 const logger = new Logger("extension");
 
@@ -125,7 +89,7 @@ export async function activate(
   } catch (e) {
     logger.error(`Error activating extension version "${extVersion}":`, e);
     // if the extension is failing to activate for whatever reason, we need to know about it to fix it
-    Sentry.captureException(e);
+    sentryCaptureException(e);
     logUsage(UserEvent.ExtensionActivation, { status: "failed" });
     throw e;
   }
@@ -453,7 +417,7 @@ export function deactivate() {
     const msg = "Error disposing telemetry logger during extension deactivation";
     logError(new Error(msg, { cause: e }), msg, {}, true);
   }
-
+  closeSentryClient();
   // close the file stream used with OUTPUT_CHANNEL
   const logStream = getLogFileStream();
   if (logStream) {
diff --git a/src/telemetry/eventProcessors.ts b/src/telemetry/eventProcessors.ts
index 7870ddbeb2..72e5e962a0 100644
--- a/src/telemetry/eventProcessors.ts
+++ b/src/telemetry/eventProcessors.ts
@@ -1,9 +1,9 @@
-import * as Sentry from "@sentry/node";
+import { Event } from "@sentry/node";
 import { env, workspace } from "vscode";
 import { observabilityContext } from "../context/observability";
 
 /** Helper function to make sure the user has Telemetry ON before sending Sentry error events */
-export function checkTelemetrySettings(event: Sentry.Event) {
+export function checkTelemetrySettings(event: Event) {
   const telemetryLevel = workspace.getConfiguration()?.get("telemetry.telemetryLevel");
   if (!env.isTelemetryEnabled || telemetryLevel === "off") {
     // Returning `null` will drop the event
@@ -13,7 +13,7 @@ export function checkTelemetrySettings(event: Sentry.Event) {
 }
 
 /** Include this extension instance's {@link observabilityContext} under the `extra` context. */
-export function includeObservabilityContext(event: Sentry.Event): Sentry.Event {
+export function includeObservabilityContext(event: Event): Event {
   event.extra = { ...event.extra, ...observabilityContext.toRecord() };
   return event;
 }
diff --git a/src/telemetry/sentryClient.ts b/src/telemetry/sentryClient.ts
new file mode 100644
index 0000000000..2223e26c56
--- /dev/null
+++ b/src/telemetry/sentryClient.ts
@@ -0,0 +1,86 @@
+import {
+  EventHint,
+  NodeClient,
+  Scope,
+  defaultStackParser,
+  getDefaultIntegrations,
+  makeNodeTransport,
+  rewriteFramesIntegration,
+} from "@sentry/node";
+import { Logger } from "../logging";
+import { checkTelemetrySettings, includeObservabilityContext } from "./eventProcessors";
+
+const logger = new Logger("sentry");
+let sentryScope: Scope | null = null;
+let sentryClient: NodeClient | null = null;
+
+/**
+ * Returns the Sentry Scope singleton, creating it if it doesn't exist
+ */
+export function getSentryScope(): Scope {
+  if (!sentryScope) {
+    logger.debug("Creating new Sentry scope");
+    sentryScope = new Scope();
+  }
+  return sentryScope;
+}
+
+/**
+ * Initialize Sentry for error tracking. Manually setup Sentry client to avoid polluting global scope.
+ * @see https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/#shared-environment-setup
+ * @see https://docs.sentry.io/platforms/node/
+ */
+export function initSentry() {
+  if (sentryClient) {
+    logger.debug("Sentry already initialized");
+    return;
+  }
+  // filter out integrations that use the global variable
+  const integrations = getDefaultIntegrations({}).filter((defaultIntegration) => {
+    return ![
+      "Breadcrumbs",
+      "BrowserAPIErrors",
+      "OnUnhandledRejection",
+      "OnUncaughtException",
+      "CaptureConsole",
+    ].includes(defaultIntegration.name);
+  });
+
+  sentryClient = new NodeClient({
+    // debug: true, // enable for local "prod" debugging with dev console
+    dsn: process.env.SENTRY_DSN,
+    environment: process.env.SENTRY_ENV,
+    release: process.env.SENTRY_RELEASE,
+    integrations: [...integrations, rewriteFramesIntegration()],
+    tracesSampleRate: 0, // We do not use Sentry tracing
+    profilesSampleRate: 0, // We do not use Sentry profiling
+    sampleRate: 1.0,
+    attachStacktrace: true,
+    includeLocalVariables: true,
+    transport: makeNodeTransport,
+    stackParser: defaultStackParser,
+  });
+
+  const scope = getSentryScope();
+  scope.setClient(sentryClient);
+  scope.addEventProcessor(checkTelemetrySettings);
+  scope.addEventProcessor(includeObservabilityContext);
+
+  sentryClient.init();
+}
+
+export function sentryCaptureException(ex: unknown, hint?: EventHint | undefined): unknown {
+  const scope = getSentryScope();
+  const client = scope.getClient();
+  if (!client) {
+    logger.error("No Sentry client available");
+    return ex;
+  }
+  logger.debug("sending exception to Sentry", { ex, hint });
+  scope.captureException(ex, hint);
+  return ex;
+}
+
+export async function closeSentryClient() {
+  await getSentryScope().getClient()?.close(2000);
+}
diff --git a/src/viewProviders/resources.ts b/src/viewProviders/resources.ts
index 42325c0c03..2efe480e0a 100644
--- a/src/viewProviders/resources.ts
+++ b/src/viewProviders/resources.ts
@@ -1,4 +1,3 @@
-import * as Sentry from "@sentry/node";
 import * as vscode from "vscode";
 import { ConnectionStatus } from "../clients/sidecar";
 import {
@@ -20,7 +19,7 @@ import {
   localSchemaRegistryConnected,
   resourceSearchSet,
 } from "../emitters";
-import { ExtensionContextNotSetError, showErrorNotificationWithButtons } from "../errors";
+import { ExtensionContextNotSetError, logError, showErrorNotificationWithButtons } from "../errors";
 import { getDirectResources } from "../graphql/direct";
 import { getLocalResources } from "../graphql/local";
 import { getCurrentOrganization } from "../graphql/organizations";
@@ -562,8 +561,7 @@ export async function loadCCloudResources(
       // if we fail to load CCloud environments, we need to get as much information as possible as to
       // what went wrong since the user is effectively locked out of the CCloud resources for this org
       const msg = `Failed to load Confluent Cloud environments for the "${currentOrg?.name}" organization.`;
-      logger.error(msg, e);
-      Sentry.captureException(e);
+      logError(e, "loading CCloud environments", {}, true);
       showErrorNotificationWithButtons(msg);
     }
 


Reviews:
user: login: noeldevelops
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T19:37:19Z
user: login: noeldevelops
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-25T22:15:23Z
user: login: shouples
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T23:49:21Z
user: login: noeldevelops
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T16:56:46Z
user: login: shouples
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T20:39:13Z
user: login: noeldevelops
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T20:40:37Z
user: login: shouples
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T20:43:02Z
user: login: noeldevelops
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T20:44:23Z
user: login: shouples
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T20:48:15Z
user: login: noeldevelops
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T20:54:33Z
user: login: shouples
body: Thanks for all the reconfiguring here @noeldevelops! Hopefully this should reduce the external noise we see from other extensions 🤞 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T21:02:07Z

Review comments:
diff_hunk: @@ -1,9 +1,109 @@
-import * as Sentry from "@sentry/node";
+import {
+  EventHint,
+  Event,
+  NodeClient,
+  Scope,
+  defaultStackParser,
+  getDefaultIntegrations,
+  makeNodeTransport,
+  rewriteFramesIntegration,
+} from "@sentry/node";
 import { env, workspace } from "vscode";
 import { observabilityContext } from "../context/observability";
+import { configDotenv } from "dotenv";
+import { Logger } from "../logging";
 
+/**
+ * Initialize Sentry for error tracking. Manually setup Sentry client to avoid polluting global scope.
+ * @see https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/#shared-environment-setup
+ * @see https://docs.sentry.io/platforms/node/
+ */
+// Previous Sentry config for reference:
+//     integrations: [
+//       // https://docs.sentry.io/platforms/javascript/configuration/filtering/#using-thirdpartyerrorfilterintegration
+//       SentryCore.thirdPartyErrorFilterIntegration({
+//         filterKeys: ["confluent-vscode-extension-sentry-do-not-use"],
+//         behaviour: "drop-error-if-exclusively-contains-third-party-frames",
+//       }),
+//       Sentry.rewriteFramesIntegration(),
+//     ],
+//     ignoreErrors: [
+//       "The request failed and the interceptors did not return an alternative response",
+//       "ENOENT: no such file or directory",
+//       "EPERM: operation not permitted",
+//       "Canceled",
+//       // rejected promises from the CCloud auth provider that can't be wrapped in try/catch:
+//       "User cancelled the authentication flow.",
+//       "User reset their password.",
+//       "Confluent Cloud authentication failed. See browser for details.",
+//     ],
+
+const logger = new Logger("sentry");
+
+const SentryScope: Scope = new Scope();
+configDotenv();
path: src/telemetry/eventProcessors.ts
user: login: noeldevelops
body: note: prob don't need this?
created_at: 2025-03-21T19:37:19Z
author_association: MEMBER
diff_hunk: @@ -134,7 +134,6 @@ export function build(done) {
         project: "vscode-extension",
         release: { name: process.env.SENTRY_RELEASE },
         disable: !process.env.SENTRY_AUTH_TOKEN,
-        applicationKey: "confluent-vscode-extension-sentry-do-not-use",
path: Gulpfile.js
user: login: noeldevelops
body: without the global handlers, we shouldn't need to add this
created_at: 2025-03-25T22:15:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,86 @@
+import {
+  EventHint,
+  NodeClient,
+  Scope,
+  defaultStackParser,
+  getDefaultIntegrations,
+  makeNodeTransport,
+  rewriteFramesIntegration,
+} from "@sentry/node";
+import { Logger } from "../logging";
+import { checkTelemetrySettings, includeObservabilityContext } from "./eventProcessors";
+
+const logger = new Logger("sentry");
+let sentryScope: Scope | null = null;
+let sentryClient: NodeClient | null = null;
+
+/**
+ * Returns the Sentry Scope singleton, creating it if it doesn't exist
+ */
+export function getSentryScope(): Scope {
+  if (!sentryScope) {
+    logger.debug("Creating new Sentry scope");
+    sentryScope = new Scope();
+  }
+  return sentryScope;
+}
+
+/**
+ * Initialize Sentry for error tracking. Manually setup Sentry client to avoid polluting global scope.
+ * @see https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/#shared-environment-setup
+ * @see https://docs.sentry.io/platforms/node/
+ */
+export function initSentry() {
+  if (sentryClient) {
+    logger.debug("Sentry already initialized");
+    return;
+  }
+  // filter out integrations that use the global variable
+  const integrations = getDefaultIntegrations({}).filter((defaultIntegration) => {
+    return ![
+      "Breadcrumbs",
+      "BrowserAPIErrors",
+      "OnUnhandledRejection",
+      "OnUncaughtException",
+      "CaptureConsole",
+    ].includes(defaultIntegration.name);
+  });
+
+  sentryClient = new NodeClient({
+    // debug: true, // enable for local "prod" debugging with dev console
+    dsn: process.env.SENTRY_DSN,
+    environment: process.env.SENTRY_ENV,
+    release: process.env.SENTRY_RELEASE,
+    integrations: [...integrations, rewriteFramesIntegration()],
+    tracesSampleRate: 0, // We do not use Sentry tracing
+    profilesSampleRate: 0, // We do not use Sentry profiling
+    sampleRate: 1.0,
+    attachStacktrace: true,
+    includeLocalVariables: true,
+    transport: makeNodeTransport,
+    stackParser: defaultStackParser,
+  });
+
+  const scope = getSentryScope();
+  scope.setClient(sentryClient);
+  scope.addEventProcessor(checkTelemetrySettings);
+  scope.addEventProcessor(includeObservabilityContext);
+
+  sentryClient.init();
+}
+
+export function sentryCaptureException(ex: unknown, hint?: EventHint | undefined): unknown {
path: src/telemetry/sentryClient.ts
user: login: shouples
body: Let's dig into this tomorrow -- it looks like the Scope's `captureException` doesn't use the same `hint` type as the (more global?) [`captureException`](https://github.com/getsentry/sentry-javascript/blob/12c5e73d96086fc7d032463af3006bcd46a94fce/packages/core/src/exports.ts#L35): 
https://github.com/getsentry/sentry-javascript/blob/12c5e73d96086fc7d032463af3006bcd46a94fce/packages/node/src/utils/prepareEvent.ts#L9-L11:
```ts
export type ExclusiveEventHintOrCaptureContext =
  | (CaptureContext & Partial<{ [key in keyof EventHint]: never }>)
  | (EventHint & Partial<{ [key in keyof ScopeContext]: never }>);
```

Maybe we can use that same `ExclusiveEventHintOrCaptureContext` in our helper function here, then check if certain keys are passed like `tags`, `context`, etc? We should then be able to use the scope's methods like [`setTags()`](https://github.com/getsentry/sentry-javascript/blob/12c5e73d96086fc7d032463af3006bcd46a94fce/packages/core/src/scope.ts#L284), [`setContext()`](https://github.com/getsentry/sentry-javascript/blob/12c5e73d96086fc7d032463af3006bcd46a94fce/packages/core/src/scope.ts#L365), and others (examples at https://docs.sentry.io/platforms/javascript/enriching-events/scopes/) or just use the same `parseEventHintOrCaptureContext()` Sentry does before that `scope.captureException` call.
created_at: 2025-03-25T23:49:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+import {
+  EventHint,
+  NodeClient,
+  Scope,
+  defaultStackParser,
+  getDefaultIntegrations,
+  makeNodeTransport,
+  rewriteFramesIntegration,
+} from "@sentry/node";
+import { Logger } from "../logging";
+import { checkTelemetrySettings, includeObservabilityContext } from "./eventProcessors";
+
+const logger = new Logger("sentry");
+let sentryScope: Scope | null = null;
+let sentryClient: NodeClient | null = null;
+
+/**
+ * Returns the Sentry Scope singleton, creating it if it doesn't exist
+ */
+export function getSentryScope(): Scope {
+  if (!sentryScope) {
+    logger.debug("Creating new Sentry scope");
+    sentryScope = new Scope();
+  }
+  return sentryScope;
+}
+
+/**
+ * Initialize Sentry for error tracking. Manually setup Sentry client to avoid polluting global scope.
+ * @see https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/#shared-environment-setup
+ * @see https://docs.sentry.io/platforms/node/
+ */
+export function initSentry() {
+  if (sentryClient) {
+    logger.debug("Sentry already initialized");
+    return;
+  }
+  // filter out integrations that use the global variable
+  const integrations = getDefaultIntegrations({}).filter((defaultIntegration) => {
+    return ![
+      "Breadcrumbs",
+      "BrowserAPIErrors",
+      "OnUnhandledRejection",
+      "OnUncaughtException",
+      "CaptureConsole",
+    ].includes(defaultIntegration.name);
+  });
+
+  sentryClient = new NodeClient({
+    // debug: true, // enable for local "prod" debugging with dev console
+    dsn: process.env.SENTRY_DSN,
+    environment: process.env.SENTRY_ENV,
+    release: process.env.SENTRY_RELEASE,
+    integrations: [...integrations, rewriteFramesIntegration()],
+    tracesSampleRate: 0, // We do not use Sentry tracing
+    profilesSampleRate: 0, // We do not use Sentry profiling
+    sampleRate: 1.0,
+    attachStacktrace: true,
+    includeLocalVariables: true,
+    transport: makeNodeTransport,
+    stackParser: defaultStackParser,
+  });
+
+  const scope = getSentryScope();
+  scope.setClient(sentryClient);
+  scope.addEventProcessor(checkTelemetrySettings);
+  scope.addEventProcessor(includeObservabilityContext);
+
+  sentryClient.init();
+}
+
+export function sentryCaptureException(ex: unknown, hint?: EventHint | undefined): unknown {
path: src/telemetry/sentryClient.ts
user: login: noeldevelops
body: ✔️ For the record: in pairing we found that `EventHint` includes `CaptureContext` as a key, so we are using that now to pass the `extra` info. In the future we want to clean up the `logError` logic to allow for passing more specific data (like `tags`) via this method. 
created_at: 2025-03-26T16:56:46Z
author_association: MEMBER
diff_hunk: @@ -137,11 +137,13 @@ export async function runWorkflowWithProgress(
               status: "workflow failed",
               start,
             });
-            Sentry.captureException(error, {
-              tags: {
-                dockerImage: workflow.imageRepoTag,
-                extensionUserFlow: "Local Resource Management",
-                localResourceKind: workflow.resourceKind,
+            sentryCaptureException(error, {
+              data: {
path: src/commands/docker.ts
user: login: shouples
body: ```suggestion
              captureContext: {
```
created_at: 2025-03-26T20:39:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,11 +137,13 @@ export async function runWorkflowWithProgress(
               status: "workflow failed",
               start,
             });
-            Sentry.captureException(error, {
-              tags: {
-                dockerImage: workflow.imageRepoTag,
-                extensionUserFlow: "Local Resource Management",
-                localResourceKind: workflow.resourceKind,
+            sentryCaptureException(error, {
+              data: {
path: src/commands/docker.ts
user: login: noeldevelops
body: 😶‍🌫️ dang it sorry! Will update
created_at: 2025-03-26T20:40:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,86 @@
+import {
+  EventHint,
+  NodeClient,
+  Scope,
+  defaultStackParser,
+  getDefaultIntegrations,
+  makeNodeTransport,
+  rewriteFramesIntegration,
+} from "@sentry/node";
+import { Logger } from "../logging";
+import { checkTelemetrySettings, includeObservabilityContext } from "./eventProcessors";
+
+const logger = new Logger("sentry");
+let sentryScope: Scope | null = null;
+let sentryClient: NodeClient | null = null;
+
+/**
+ * Returns the Sentry Scope singleton, creating it if it doesn't exist
+ */
+export function getSentryScope(): Scope {
+  if (!sentryScope) {
+    logger.debug("Creating new Sentry scope");
+    sentryScope = new Scope();
+  }
+  return sentryScope;
+}
+
+/**
+ * Initialize Sentry for error tracking. Manually setup Sentry client to avoid polluting global scope.
+ * @see https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/#shared-environment-setup
+ * @see https://docs.sentry.io/platforms/node/
+ */
+export function initSentry() {
+  if (sentryClient) {
+    logger.debug("Sentry already initialized");
+    return;
+  }
+  // filter out integrations that use the global variable
+  const integrations = getDefaultIntegrations({}).filter((defaultIntegration) => {
+    return ![
+      "Breadcrumbs",
+      "BrowserAPIErrors",
+      "OnUnhandledRejection",
+      "OnUncaughtException",
+      "CaptureConsole",
+    ].includes(defaultIntegration.name);
+  });
+
+  sentryClient = new NodeClient({
+    // debug: true, // enable for local "prod" debugging with dev console
+    dsn: process.env.SENTRY_DSN,
+    environment: process.env.SENTRY_ENV,
+    release: process.env.SENTRY_RELEASE,
+    integrations: [...integrations, rewriteFramesIntegration()],
+    tracesSampleRate: 0, // We do not use Sentry tracing
+    profilesSampleRate: 0, // We do not use Sentry profiling
+    sampleRate: 1.0,
+    attachStacktrace: true,
+    includeLocalVariables: true,
+    transport: makeNodeTransport,
+    stackParser: defaultStackParser,
+  });
+
+  const scope = getSentryScope();
+  scope.setClient(sentryClient);
+  scope.addEventProcessor(checkTelemetrySettings);
+  scope.addEventProcessor(includeObservabilityContext);
+
+  sentryClient.init();
+}
+
+export function sentryCaptureException(ex: unknown, hint?: EventHint | undefined): unknown {
+  const scope = getSentryScope();
+  const client = scope.getClient();
+  if (!client) {
+    logger.error("No Sentry client available");
+    return ex;
+  }
+  logger.debug(`Sending to Sentry`);
path: src/telemetry/sentryClient.ts
user: login: shouples
body: Did we want the `ex` or `hint` in this debug log?
created_at: 2025-03-26T20:43:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+import {
+  EventHint,
+  NodeClient,
+  Scope,
+  defaultStackParser,
+  getDefaultIntegrations,
+  makeNodeTransport,
+  rewriteFramesIntegration,
+} from "@sentry/node";
+import { Logger } from "../logging";
+import { checkTelemetrySettings, includeObservabilityContext } from "./eventProcessors";
+
+const logger = new Logger("sentry");
+let sentryScope: Scope | null = null;
+let sentryClient: NodeClient | null = null;
+
+/**
+ * Returns the Sentry Scope singleton, creating it if it doesn't exist
+ */
+export function getSentryScope(): Scope {
+  if (!sentryScope) {
+    logger.debug("Creating new Sentry scope");
+    sentryScope = new Scope();
+  }
+  return sentryScope;
+}
+
+/**
+ * Initialize Sentry for error tracking. Manually setup Sentry client to avoid polluting global scope.
+ * @see https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/#shared-environment-setup
+ * @see https://docs.sentry.io/platforms/node/
+ */
+export function initSentry() {
+  if (sentryClient) {
+    logger.debug("Sentry already initialized");
+    return;
+  }
+  // filter out integrations that use the global variable
+  const integrations = getDefaultIntegrations({}).filter((defaultIntegration) => {
+    return ![
+      "Breadcrumbs",
+      "BrowserAPIErrors",
+      "OnUnhandledRejection",
+      "OnUncaughtException",
+      "CaptureConsole",
+    ].includes(defaultIntegration.name);
+  });
+
+  sentryClient = new NodeClient({
+    // debug: true, // enable for local "prod" debugging with dev console
+    dsn: process.env.SENTRY_DSN,
+    environment: process.env.SENTRY_ENV,
+    release: process.env.SENTRY_RELEASE,
+    integrations: [...integrations, rewriteFramesIntegration()],
+    tracesSampleRate: 0, // We do not use Sentry tracing
+    profilesSampleRate: 0, // We do not use Sentry profiling
+    sampleRate: 1.0,
+    attachStacktrace: true,
+    includeLocalVariables: true,
+    transport: makeNodeTransport,
+    stackParser: defaultStackParser,
+  });
+
+  const scope = getSentryScope();
+  scope.setClient(sentryClient);
+  scope.addEventProcessor(checkTelemetrySettings);
+  scope.addEventProcessor(includeObservabilityContext);
+
+  sentryClient.init();
+}
+
+export function sentryCaptureException(ex: unknown, hint?: EventHint | undefined): unknown {
+  const scope = getSentryScope();
+  const client = scope.getClient();
+  if (!client) {
+    logger.error("No Sentry client available");
+    return ex;
+  }
+  logger.debug(`Sending to Sentry`);
path: src/telemetry/sentryClient.ts
user: login: noeldevelops
body: Sure I'm open to adding it, or removing the log altogether (it was originally just to make sure things were going through here). WDYT?
created_at: 2025-03-26T20:44:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,86 @@
+import {
+  EventHint,
+  NodeClient,
+  Scope,
+  defaultStackParser,
+  getDefaultIntegrations,
+  makeNodeTransport,
+  rewriteFramesIntegration,
+} from "@sentry/node";
+import { Logger } from "../logging";
+import { checkTelemetrySettings, includeObservabilityContext } from "./eventProcessors";
+
+const logger = new Logger("sentry");
+let sentryScope: Scope | null = null;
+let sentryClient: NodeClient | null = null;
+
+/**
+ * Returns the Sentry Scope singleton, creating it if it doesn't exist
+ */
+export function getSentryScope(): Scope {
+  if (!sentryScope) {
+    logger.debug("Creating new Sentry scope");
+    sentryScope = new Scope();
+  }
+  return sentryScope;
+}
+
+/**
+ * Initialize Sentry for error tracking. Manually setup Sentry client to avoid polluting global scope.
+ * @see https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/#shared-environment-setup
+ * @see https://docs.sentry.io/platforms/node/
+ */
+export function initSentry() {
+  if (sentryClient) {
+    logger.debug("Sentry already initialized");
+    return;
+  }
+  // filter out integrations that use the global variable
+  const integrations = getDefaultIntegrations({}).filter((defaultIntegration) => {
+    return ![
+      "Breadcrumbs",
+      "BrowserAPIErrors",
+      "OnUnhandledRejection",
+      "OnUncaughtException",
+      "CaptureConsole",
+    ].includes(defaultIntegration.name);
+  });
+
+  sentryClient = new NodeClient({
+    // debug: true, // enable for local "prod" debugging with dev console
+    dsn: process.env.SENTRY_DSN,
+    environment: process.env.SENTRY_ENV,
+    release: process.env.SENTRY_RELEASE,
+    integrations: [...integrations, rewriteFramesIntegration()],
+    tracesSampleRate: 0, // We do not use Sentry tracing
+    profilesSampleRate: 0, // We do not use Sentry profiling
+    sampleRate: 1.0,
+    attachStacktrace: true,
+    includeLocalVariables: true,
+    transport: makeNodeTransport,
+    stackParser: defaultStackParser,
+  });
+
+  const scope = getSentryScope();
+  scope.setClient(sentryClient);
+  scope.addEventProcessor(checkTelemetrySettings);
+  scope.addEventProcessor(includeObservabilityContext);
+
+  sentryClient.init();
+}
+
+export function sentryCaptureException(ex: unknown, hint?: EventHint | undefined): unknown {
+  const scope = getSentryScope();
+  const client = scope.getClient();
+  if (!client) {
+    logger.error("No Sentry client available");
+    return ex;
+  }
+  logger.debug(`Sending to Sentry`);
path: src/telemetry/sentryClient.ts
user: login: shouples
body: I'm generally in favor of it, especially given how nice our Segment `logUsage` debug logging can be during development https://github.com/confluentinc/vscode/blob/8d885032ad51c7cd081a2f7302b832d269707d26/src/telemetry/events.ts#L25-L26
created_at: 2025-03-26T20:48:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,86 @@
+import {
+  EventHint,
+  NodeClient,
+  Scope,
+  defaultStackParser,
+  getDefaultIntegrations,
+  makeNodeTransport,
+  rewriteFramesIntegration,
+} from "@sentry/node";
+import { Logger } from "../logging";
+import { checkTelemetrySettings, includeObservabilityContext } from "./eventProcessors";
+
+const logger = new Logger("sentry");
+let sentryScope: Scope | null = null;
+let sentryClient: NodeClient | null = null;
+
+/**
+ * Returns the Sentry Scope singleton, creating it if it doesn't exist
+ */
+export function getSentryScope(): Scope {
+  if (!sentryScope) {
+    logger.debug("Creating new Sentry scope");
+    sentryScope = new Scope();
+  }
+  return sentryScope;
+}
+
+/**
+ * Initialize Sentry for error tracking. Manually setup Sentry client to avoid polluting global scope.
+ * @see https://docs.sentry.io/platforms/javascript/best-practices/shared-environments/#shared-environment-setup
+ * @see https://docs.sentry.io/platforms/node/
+ */
+export function initSentry() {
+  if (sentryClient) {
+    logger.debug("Sentry already initialized");
+    return;
+  }
+  // filter out integrations that use the global variable
+  const integrations = getDefaultIntegrations({}).filter((defaultIntegration) => {
+    return ![
+      "Breadcrumbs",
+      "BrowserAPIErrors",
+      "OnUnhandledRejection",
+      "OnUncaughtException",
+      "CaptureConsole",
+    ].includes(defaultIntegration.name);
+  });
+
+  sentryClient = new NodeClient({
+    // debug: true, // enable for local "prod" debugging with dev console
+    dsn: process.env.SENTRY_DSN,
+    environment: process.env.SENTRY_ENV,
+    release: process.env.SENTRY_RELEASE,
+    integrations: [...integrations, rewriteFramesIntegration()],
+    tracesSampleRate: 0, // We do not use Sentry tracing
+    profilesSampleRate: 0, // We do not use Sentry profiling
+    sampleRate: 1.0,
+    attachStacktrace: true,
+    includeLocalVariables: true,
+    transport: makeNodeTransport,
+    stackParser: defaultStackParser,
+  });
+
+  const scope = getSentryScope();
+  scope.setClient(sentryClient);
+  scope.addEventProcessor(checkTelemetrySettings);
+  scope.addEventProcessor(includeObservabilityContext);
+
+  sentryClient.init();
+}
+
+export function sentryCaptureException(ex: unknown, hint?: EventHint | undefined): unknown {
+  const scope = getSentryScope();
+  const client = scope.getClient();
+  if (!client) {
+    logger.error("No Sentry client available");
+    return ex;
+  }
+  logger.debug(`Sending to Sentry`);
path: src/telemetry/sentryClient.ts
user: login: noeldevelops
body: cool, sounds good to me. I'm going to add both `ex` & `hint` for now. It may be noisy (`ex` can be large). If so, we'll follow up and log only a portion of the data
created_at: 2025-03-26T20:54:32Z
author_association: MEMBER

PR comments:
user: login: noeldevelops
created_at: 2025-03-25T22:14:00Z
body: ❗ Ready for review, but I want to test a bit more before merging this. 
author_association: MEMBER
user: login: noeldevelops
created_at: 2025-03-25T22:14:04Z
body: @shouples - I may need guidance on whether I'm using `logError` as intended. In some cases it seemed better to keep the logger and Sentry call separate (e.g. Docker), 🤔 and I'm not sure what's best for the `messagePrefix` param. 
author_association: MEMBER
user: login: shouples
created_at: 2025-03-25T23:16:02Z
body: > @shouples - I may need guidance on whether I'm using `logError` as intended. In some cases it seemed better to keep the logger and Sentry call separate (e.g. Docker), 🤔 and I'm not sure what's best for the `messagePrefix` param.

They all look fine to me the way you have them in this branch. 👍 Though I would like to rework `logError` a bit (maybe with https://github.com/confluentinc/vscode/pull/1061) since it does feel a little clunky in its current state.
author_association: CONTRIBUTOR