diff --git a/.gitignore b/.gitignore
index 0bd4a1372..f99f2decb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,6 @@ apps/**/generated
# bruno
cloud.bru
+
+
+analyze
\ No newline at end of file
diff --git a/apps/avatax/package.json b/apps/avatax/package.json
index ffa678582..c7aa4c635 100644
--- a/apps/avatax/package.json
+++ b/apps/avatax/package.json
@@ -52,6 +52,7 @@
"@trpc/react-query": "10.43.1",
"@trpc/server": "10.43.1",
"@urql/exchange-auth": "^2.1.4",
+ "@vercel/functions": "1.0.2",
"avatax": "^23.7.0",
"dotenv": "^16.3.1",
"graphql": "16.7.1",
diff --git a/apps/avatax/src/lib/app-config.test.ts b/apps/avatax/src/lib/app-config.test.ts
index c1b6dfda1..aeb9dbc10 100644
--- a/apps/avatax/src/lib/app-config.test.ts
+++ b/apps/avatax/src/lib/app-config.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from "vitest";
+import { describe, expect, it } from "vitest";
import { AppConfig } from "./app-config";
describe("AppConfig", () => {
@@ -50,6 +50,10 @@ describe("AppConfig", () => {
isAutocommit: false,
isDocumentRecordingEnabled: false,
shippingTaxCode: "123",
+ logsSettings: {
+ otel: {},
+ json: {},
+ },
},
},
],
@@ -118,6 +122,10 @@ describe("AppConfig", () => {
isAutocommit: false,
isDocumentRecordingEnabled: false,
shippingTaxCode: "123",
+ logsSettings: {
+ otel: {},
+ json: {},
+ },
},
},
],
diff --git a/apps/avatax/src/lib/app-configuration-logger.test.ts b/apps/avatax/src/lib/app-configuration-logger.test.ts
index 4eaca4691..293342b8b 100644
--- a/apps/avatax/src/lib/app-configuration-logger.test.ts
+++ b/apps/avatax/src/lib/app-configuration-logger.test.ts
@@ -1,7 +1,7 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { AppConfigurationLogger } from "./app-configuration-logger";
-import { AppConfig } from "./app-config";
import { ObservabilityAttributes } from "@saleor/apps-otel/src/lib/observability-attributes";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { AppConfig } from "./app-config";
+import { AppConfigurationLogger } from "./app-configuration-logger";
describe("AppConfigurationLogger", () => {
const mockWarn = vi.fn();
@@ -72,6 +72,10 @@ describe("AppConfigurationLogger", () => {
isAutocommit: false,
isDocumentRecordingEnabled: false,
shippingTaxCode: "123",
+ logsSettings: {
+ otel: {},
+ json: {},
+ },
},
},
],
diff --git a/apps/avatax/src/modules/app/get-app-config.test.ts b/apps/avatax/src/modules/app/get-app-config.test.ts
index 47443f5e4..ff460c342 100644
--- a/apps/avatax/src/modules/app/get-app-config.test.ts
+++ b/apps/avatax/src/modules/app/get-app-config.test.ts
@@ -1,9 +1,9 @@
import { encrypt } from "@saleor/app-sdk/settings-manager";
-import { getAppConfig } from "./get-app-config";
import { describe, expect, it, vi } from "vitest";
-import { ProviderConnections } from "../provider-connections/provider-connections";
import { MetadataItem } from "../../../generated/graphql";
import { ChannelsConfig } from "../channel-configuration/channel-config";
+import { ProviderConnections } from "../provider-connections/provider-connections";
+import { getAppConfig } from "./get-app-config";
const mockedSecretKey = "test_secret_key";
const mockedProviders: ProviderConnections = [
@@ -28,6 +28,10 @@ const mockedProviders: ProviderConnections = [
street: "123 Main St",
zip: "10001",
},
+ logsSettings: {
+ otel: {},
+ json: {},
+ },
},
},
];
diff --git a/apps/avatax/src/modules/avatax/avatax-config-mock-generator.ts b/apps/avatax/src/modules/avatax/avatax-config-mock-generator.ts
index 934a5bdb0..7f38383e5 100644
--- a/apps/avatax/src/modules/avatax/avatax-config-mock-generator.ts
+++ b/apps/avatax/src/modules/avatax/avatax-config-mock-generator.ts
@@ -18,6 +18,10 @@ const defaultAvataxConfig: AvataxConfig = {
password: "password",
username: "username",
},
+ logsSettings: {
+ otel: {},
+ json: {},
+ },
};
const testingScenariosMap = {
diff --git a/apps/avatax/src/modules/avatax/avatax-connection-schema.ts b/apps/avatax/src/modules/avatax/avatax-connection-schema.ts
index 3583c4880..c5013c501 100644
--- a/apps/avatax/src/modules/avatax/avatax-connection-schema.ts
+++ b/apps/avatax/src/modules/avatax/avatax-connection-schema.ts
@@ -29,6 +29,18 @@ export const avataxConfigSchema = z
shippingTaxCode: z.string().optional(),
isDocumentRecordingEnabled: z.boolean().default(true),
address: addressSchema,
+ logsSettings: z.object({
+ otel: z.object({
+ enabled: z.boolean().optional(),
+ url: z.string().optional(),
+ headers: z.string().optional(),
+ }),
+ json: z.object({
+ enabled: z.boolean().optional(),
+ url: z.string().optional(),
+ headers: z.string().optional(),
+ }),
+ }),
})
.merge(baseAvataxConfigSchema);
@@ -52,6 +64,18 @@ export const defaultAvataxConfig: AvataxConfig = {
street: "",
zip: "",
},
+ logsSettings: {
+ otel: {
+ enabled: false,
+ url: "",
+ headers: "",
+ },
+ json: {
+ enabled: false,
+ url: "",
+ headers: "",
+ },
+ },
};
export const avataxConnectionSchema = z.object({
diff --git a/apps/avatax/src/modules/avatax/configuration/avatax-connection.service.ts b/apps/avatax/src/modules/avatax/configuration/avatax-connection.service.ts
index d6d3cf3f2..48c586f53 100644
--- a/apps/avatax/src/modules/avatax/configuration/avatax-connection.service.ts
+++ b/apps/avatax/src/modules/avatax/configuration/avatax-connection.service.ts
@@ -78,6 +78,10 @@ export class AvataxConnectionService {
...prevConfig.address,
...nextConfigPartial.address,
},
+ logsSettings: {
+ ...prevConfig.logsSettings,
+ ...nextConfigPartial.logsSettings,
+ },
};
await this.checkIfAuthorized(input);
diff --git a/apps/avatax/src/modules/avatax/ui/avatax-configuration-form.tsx b/apps/avatax/src/modules/avatax/ui/avatax-configuration-form.tsx
index 35f5a4e6a..4208a1b04 100644
--- a/apps/avatax/src/modules/avatax/ui/avatax-configuration-form.tsx
+++ b/apps/avatax/src/modules/avatax/ui/avatax-configuration-form.tsx
@@ -17,6 +17,7 @@ import { AvataxConfigurationCredentialsFragment } from "./avatax-configuration-c
import { AvataxConfigurationSettingsFragment } from "./avatax-configuration-settings-fragment";
import { useAvataxConfigurationStatus } from "./configuration-status";
import { HelperText } from "./form-helper-text";
+import { LogsSettingsFragment } from "./logs-settings-fragment";
type AvataxConfigurationFormProps = {
submit: {
@@ -83,6 +84,8 @@ export const AvataxConfigurationForm = (props: AvataxConfigurationFormProps) =>
isLoading={props.validateAddress.isLoading}
/>
+
+
{props.leftButton}
diff --git a/apps/avatax/src/modules/avatax/ui/edit-avatax-configuration.tsx b/apps/avatax/src/modules/avatax/ui/edit-avatax-configuration.tsx
index 429109230..8bd048507 100644
--- a/apps/avatax/src/modules/avatax/ui/edit-avatax-configuration.tsx
+++ b/apps/avatax/src/modules/avatax/ui/edit-avatax-configuration.tsx
@@ -4,8 +4,8 @@ import { useRouter } from "next/router";
import React from "react";
import { z } from "zod";
import { trpcClient } from "../../trpc/trpc-client";
-import { AvataxObfuscator } from "../avatax-obfuscator";
import { AvataxConfig, BaseAvataxConfig } from "../avatax-connection-schema";
+import { AvataxObfuscator } from "../avatax-obfuscator";
import { AvataxConfigurationForm } from "./avatax-configuration-form";
import { useAvataxConfigurationStatus } from "./configuration-status";
@@ -141,6 +141,8 @@ export const EditAvataxConfiguration = () => {
);
}
+ console.log("config", data.config);
+
return (
{
+ const { control, formState } = useFormContext();
+ const isOtelTogleEnabled = useWatch({
+ control,
+ name: "logsSettings.otel.enabled",
+ });
+ const isJSONTogleEnabled = useWatch({
+ control,
+ name: "logsSettings.json.enabled",
+ });
+
+ return (
+ <>
+
+
+ Logs settings
+
+
+
+ Configure where AvaTax should emit logs. This is useful for debugging and
+ troubleshooting connection issues.
+
+ Enable sending logs using OpenTelemetry protocol.}
+ />
+
+
+ (
+
+ )}
+ />
+
+
+ Enable sending logs using json protocol.}
+ />
+
+
+ (
+
+ )}
+ />
+
+
+
+ >
+ );
+};
diff --git a/apps/avatax/src/modules/calculate-taxes/use-case/calculate-taxes.use-case.test.ts b/apps/avatax/src/modules/calculate-taxes/use-case/calculate-taxes.use-case.test.ts
index f811b6669..0fa2b4007 100644
--- a/apps/avatax/src/modules/calculate-taxes/use-case/calculate-taxes.use-case.test.ts
+++ b/apps/avatax/src/modules/calculate-taxes/use-case/calculate-taxes.use-case.test.ts
@@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { BaseError } from "../../../error";
import { AppConfig } from "../../../lib/app-config";
import { AppConfigExtractor, IAppConfigExtractor } from "../../../lib/app-config-extractor";
+import { PublicLog } from "../../public-log-drain/public-events";
+import { PublicLogDrainService } from "../../public-log-drain/public-log-drain.service";
import { AvataxWebhookServiceFactory } from "../../taxes/avatax-webhook-service-factory";
import { CalculateTaxesPayload } from "../../webhooks/payloads/calculate-taxes-payload";
import { CalculateTaxesUseCase } from "./calculate-taxes.use-case";
@@ -113,6 +115,16 @@ const getMockedAppConfig = (): AppConfig => {
isAutocommit: false,
isDocumentRecordingEnabled: false,
shippingTaxCode: "123",
+ logsSettings: {
+ otel: {
+ url: "https://otel.example.com",
+ headers: "Authorization",
+ },
+ json: {
+ url: "https://http.example.com",
+ headers: "Authorization",
+ },
+ },
},
},
],
@@ -127,6 +139,11 @@ describe("CalculateTaxesUseCase", () => {
instance = new CalculateTaxesUseCase({
configExtractor: MockConfigExtractor,
+ publicLogDrain: new PublicLogDrainService([
+ {
+ async emit(log: PublicLog): Promise {},
+ },
+ ]),
});
});
diff --git a/apps/avatax/src/modules/calculate-taxes/use-case/calculate-taxes.use-case.ts b/apps/avatax/src/modules/calculate-taxes/use-case/calculate-taxes.use-case.ts
index 14d599d2c..08f190c49 100644
--- a/apps/avatax/src/modules/calculate-taxes/use-case/calculate-taxes.use-case.ts
+++ b/apps/avatax/src/modules/calculate-taxes/use-case/calculate-taxes.use-case.ts
@@ -1,18 +1,30 @@
+import { AuthData } from "@saleor/app-sdk/APL";
import { createLogger } from "@saleor/apps-logger";
+import * as Sentry from "@sentry/nextjs";
+import { captureException } from "@sentry/nextjs";
+import { waitUntil } from "@vercel/functions";
+import { Result, err, fromPromise } from "neverthrow";
+import { MetadataItem } from "../../../../generated/graphql";
import { BaseError } from "../../../error";
import { AppConfigExtractor, IAppConfigExtractor } from "../../../lib/app-config-extractor";
-import { CalculateTaxesPayload } from "../../webhooks/payloads/calculate-taxes-payload";
-import { AuthData } from "@saleor/app-sdk/APL";
-import { verifyCalculateTaxesPayload } from "../../webhooks/validate-webhook-payload";
-import { TaxIncompletePayloadErrors } from "../../taxes/tax-error";
-import { err, fromPromise, Result } from "neverthrow";
import { AppConfigurationLogger } from "../../../lib/app-configuration-logger";
-import * as Sentry from "@sentry/nextjs";
-import { captureException } from "@sentry/nextjs";
import { AvataxCalculateTaxesResponse } from "../../avatax/calculate-taxes/avatax-calculate-taxes-adapter";
-import { MetadataItem } from "../../../../generated/graphql";
+import {
+ TaxesCalculatedInCheckoutLog,
+ TaxesCalculatedInOrderLog,
+ TaxesCalculationFailedConfigErrorLog,
+ TaxesCalculationFailedInvalidPayloadLog,
+ TaxesCalculationFailedUnhandledErrorLog,
+ TaxesCalculationProviderErrorLog,
+} from "../../public-log-drain/public-events";
+import { PublicLogDrain } from "../../public-log-drain/public-log-drain";
+import { LogDrainJsonTransporter } from "../../public-log-drain/transporters/public-log-drain-json-transporter";
+import { LogDrainOtelTransporter } from "../../public-log-drain/transporters/public-log-drain-otel-transporter";
+import { TaxIncompletePayloadErrors } from "../../taxes/tax-error";
+import { CalculateTaxesPayload } from "../../webhooks/payloads/calculate-taxes-payload";
+import { verifyCalculateTaxesPayload } from "../../webhooks/validate-webhook-payload";
-export class CalculateTaxesUseCase {
+class PrivateCalculateTaxesUseCase {
private logger = createLogger("CalculateTaxesUseCase");
static CalculateTaxesUseCaseError = BaseError.subclass("CalculateTaxesUseCaseError");
@@ -26,8 +38,9 @@ export class CalculateTaxesUseCase {
);
constructor(
- private deps: {
+ protected deps: {
configExtractor: IAppConfigExtractor;
+ publicLogDrain: PublicLogDrain;
},
) {}
@@ -162,6 +175,48 @@ export class CalculateTaxesUseCase {
);
}
+ if (providerConfig.value.avataxConfig.config.logsSettings?.otel.enabled) {
+ const otelLogDrainTransporter = new LogDrainOtelTransporter();
+ let headers: Record;
+
+ try {
+ headers = JSON.parse(
+ providerConfig.value.avataxConfig.config.logsSettings.otel.headers ?? "",
+ );
+ } catch {
+ headers = {};
+ }
+ const url = providerConfig.value.avataxConfig.config.logsSettings.otel.url ?? "";
+
+ otelLogDrainTransporter.setSettings({
+ url,
+ headers,
+ });
+
+ this.deps.publicLogDrain.addTransporter(otelLogDrainTransporter);
+ }
+
+ if (providerConfig.value.avataxConfig.config.logsSettings?.json.enabled) {
+ const jsonLogDrainTransporter = new LogDrainJsonTransporter();
+ let headers: Record;
+
+ try {
+ headers = JSON.parse(
+ providerConfig.value.avataxConfig.config.logsSettings.json.headers ?? "",
+ );
+ } catch {
+ headers = {};
+ }
+ const url = providerConfig.value.avataxConfig.config.logsSettings.json.url ?? "";
+
+ jsonLogDrainTransporter.setSettings({
+ endpoint: url,
+ headers,
+ });
+
+ this.deps.publicLogDrain.addTransporter(jsonLogDrainTransporter);
+ }
+
return fromPromise(
taxProvider.calculateTaxes(payload, providerConfig.value.avataxConfig.config, authData),
(err) =>
@@ -175,3 +230,82 @@ export class CalculateTaxesUseCase {
});
}
}
+
+export class CalculateTaxesUseCase extends PrivateCalculateTaxesUseCase {
+ constructor(deps: { configExtractor: IAppConfigExtractor; publicLogDrain: PublicLogDrain }) {
+ super(deps);
+ }
+
+ async calculateTaxes(
+ payload: CalculateTaxesPayload,
+ authData: AuthData,
+ ): Promise<
+ Result<
+ AvataxCalculateTaxesResponse,
+ (typeof CalculateTaxesUseCase.CalculateTaxesUseCaseError)["prototype"]
+ >
+ > {
+ const sourceObjectType = payload.taxBase.sourceObject.__typename;
+ const sourceObjectId = payload.taxBase.sourceObject.id;
+ const orderOrCheckoutId =
+ payload.taxBase.sourceObject.__typename === "Checkout"
+ ? { checkoutId: sourceObjectId }
+ : { orderId: sourceObjectId };
+ const saleorApiUrl = authData.saleorApiUrl;
+
+ const result = await super.calculateTaxes(payload, authData);
+
+ return result
+ .map((output) => {
+ const log =
+ sourceObjectType === "Checkout"
+ ? new TaxesCalculatedInCheckoutLog({ checkoutId: sourceObjectId, saleorApiUrl })
+ : new TaxesCalculatedInOrderLog({ orderId: sourceObjectId, saleorApiUrl });
+
+ waitUntil(this.deps.publicLogDrain.emitLog(log));
+
+ return output;
+ })
+ .mapErr((err) => {
+ let log;
+
+ if (err instanceof CalculateTaxesUseCase.ConfigBrokenError) {
+ log = new TaxesCalculationFailedConfigErrorLog({
+ ...orderOrCheckoutId,
+ saleorApiUrl,
+ });
+ }
+
+ if (err instanceof CalculateTaxesUseCase.ExpectedIncompletePayloadError) {
+ log = new TaxesCalculationFailedInvalidPayloadLog({
+ ...orderOrCheckoutId,
+ saleorApiUrl,
+ });
+ }
+
+ if (err instanceof CalculateTaxesUseCase.FailedCalculatingTaxesError) {
+ log = new TaxesCalculationProviderErrorLog({
+ ...orderOrCheckoutId,
+ saleorApiUrl,
+ });
+ }
+
+ if (err instanceof CalculateTaxesUseCase.UnhandledError) {
+ log = new TaxesCalculationFailedUnhandledErrorLog({
+ ...orderOrCheckoutId,
+ saleorApiUrl,
+ });
+ }
+ if (!log) {
+ log = new TaxesCalculationFailedUnhandledErrorLog({
+ ...orderOrCheckoutId,
+ saleorApiUrl,
+ });
+ }
+
+ waitUntil(this.deps.publicLogDrain.emitLog(log));
+
+ return err;
+ });
+ }
+}
diff --git a/apps/avatax/src/modules/channel-configuration/channel-configuration-merger.ts b/apps/avatax/src/modules/channel-configuration/channel-configuration-merger.ts
index 2d21b6c35..30bb1b25c 100644
--- a/apps/avatax/src/modules/channel-configuration/channel-configuration-merger.ts
+++ b/apps/avatax/src/modules/channel-configuration/channel-configuration-merger.ts
@@ -13,6 +13,7 @@ export class ChannelConfigurationMerger {
config: {
providerConnectionId: null,
slug: channel.slug,
+ orderSettings: null,
},
};
}
@@ -22,6 +23,7 @@ export class ChannelConfigurationMerger {
config: {
providerConnectionId: channelConfig.config.providerConnectionId,
slug: channel.slug,
+ orderSettings: null,
},
};
});
diff --git a/apps/avatax/src/modules/public-log-drain/public-events.ts b/apps/avatax/src/modules/public-log-drain/public-events.ts
new file mode 100644
index 000000000..d074e9432
--- /dev/null
+++ b/apps/avatax/src/modules/public-log-drain/public-events.ts
@@ -0,0 +1,191 @@
+export const LogSeverityLevel = {
+ TRACE: "TRACE",
+ DEBUG: "DEBUG",
+ INFO: "INFO",
+ WARN: "WARN",
+ ERROR: "ERROR",
+ FATAL: "FATAL",
+} as const;
+
+export type LogSeverityLevelType = keyof typeof LogSeverityLevel;
+
+const EventTypes = {
+ TAXES_CALCULATED: "TAXES_CALCULATED",
+ TAXES_CALCULATION_FAILED: "TAXES_CALCULATION_FAILED",
+ AVATAX_TRANSACTION_CREATED: "AVATAX_TRANSACTION_CREATED",
+ AVATAX_TRANSACTION_CREATION_FAILED: "AVATAX_TRANSACTION_CREATION_FAILED",
+ SALEOR_ORDER_CONFIRMED: "SALEOR_ORDER_CONFIRMED",
+ SALEOR_ORDER_CONFIRMATION_FAILED: "SALEOR_ORDER_CONFIRMATION_FAILED",
+} as const;
+
+export interface PublicLog = {}> {
+ message: string;
+
+ timestamp: Date;
+ level: LogSeverityLevelType;
+ attributes: T & {
+ saleorApiUrl: string;
+ eventType: keyof typeof EventTypes;
+ };
+}
+
+export class TaxesCalculatedInCheckoutLog implements PublicLog<{ checkoutId: string }> {
+ message = "Taxes calculated in checkout";
+ timestamp = new Date();
+ level = LogSeverityLevel.INFO;
+ attributes = { checkoutId: "", saleorApiUrl: "", eventType: EventTypes.TAXES_CALCULATED };
+
+ constructor(params: { checkoutId: string; saleorApiUrl: string }) {
+ this.attributes.checkoutId = params.checkoutId;
+ this.attributes.saleorApiUrl = params.saleorApiUrl;
+ }
+}
+
+export class TaxesCalculatedInOrderLog implements PublicLog<{ orderId: string }> {
+ message = "Taxes calculated in order";
+ timestamp = new Date();
+ level = LogSeverityLevel.INFO;
+ attributes = { orderId: "", saleorApiUrl: "", eventType: EventTypes.TAXES_CALCULATED };
+
+ constructor(params: { orderId: string; saleorApiUrl: string }) {
+ this.attributes.orderId = params.orderId;
+ this.attributes.saleorApiUrl = params.saleorApiUrl;
+ }
+}
+
+type CheckoutOrOrderId =
+ | {
+ orderId: string;
+ checkoutId?: undefined;
+ }
+ | {
+ orderId?: undefined;
+ checkoutId: string;
+ };
+
+class TaxesCalculationFailedLog implements PublicLog {
+ message = "Taxes calculation failed";
+ timestamp = new Date();
+ level = LogSeverityLevel.ERROR as LogSeverityLevelType;
+ attributes = {
+ saleorApiUrl: "",
+ eventType: EventTypes.TAXES_CALCULATION_FAILED,
+ } as PublicLog["attributes"];
+
+ constructor(params: CheckoutOrOrderId & { saleorApiUrl: string }) {
+ if (params.orderId) {
+ this.attributes.orderId = params.orderId;
+ } else {
+ this.attributes.checkoutId = params.checkoutId;
+ }
+ this.attributes.saleorApiUrl = params.saleorApiUrl;
+ this.attributes.eventType = EventTypes.TAXES_CALCULATION_FAILED;
+ }
+}
+
+export class TaxesCalculationFailedConfigErrorLog extends TaxesCalculationFailedLog {
+ message = "Taxes calculation failed due to wrong configuration";
+
+ constructor(params: CheckoutOrOrderId & { saleorApiUrl: string; additionalMessage?: string }) {
+ super(params);
+ if (params.additionalMessage) {
+ this.message += `: ${params.additionalMessage}`;
+ }
+ }
+}
+
+export class TaxesCalculationFailedInvalidPayloadLog extends TaxesCalculationFailedLog {
+ message = "Taxes calculation failed due to invalid payload";
+
+ constructor(params: CheckoutOrOrderId & { saleorApiUrl: string; additionalMessage?: string }) {
+ super(params);
+ if (params.additionalMessage) {
+ this.message += `: ${params.additionalMessage}`;
+ }
+ }
+}
+
+export class TaxesCalculationProviderErrorLog extends TaxesCalculationFailedLog {
+ message = "Taxes calculation failed due to provider error";
+
+ constructor(params: CheckoutOrOrderId & { saleorApiUrl: string }) {
+ super(params);
+ }
+}
+
+export class TaxesCalculationFailedUnhandledErrorLog extends TaxesCalculationFailedLog {
+ message = "Taxes calculation failed due to unhandled error";
+ level = LogSeverityLevel.FATAL;
+
+ constructor(params: CheckoutOrOrderId & { saleorApiUrl: string }) {
+ super(params);
+ }
+}
+
+export class AvataxTransactionCreatedLog
+ implements PublicLog<{ orderId: string; avataxTransactionId: string }>
+{
+ message = "Avatax transaction created";
+ timestamp = new Date();
+ level = LogSeverityLevel.INFO;
+ attributes = {
+ orderId: "",
+ avataxTransactionId: "",
+ saleorApiUrl: "",
+ eventType: EventTypes.AVATAX_TRANSACTION_CREATED,
+ };
+
+ constructor(params: { orderId: string; avataxTransactionId: string; saleorApiUrl: string }) {
+ this.attributes.orderId = params.orderId;
+ this.attributes.avataxTransactionId = params.avataxTransactionId;
+ this.attributes.saleorApiUrl = params.saleorApiUrl;
+ }
+}
+
+class AvataxTransactionCreationError
+ implements PublicLog<{ orderId: string; avataxTransactionId?: string }>
+{
+ message = "Avatax transaction creation failed";
+ timestamp = new Date();
+ level = LogSeverityLevel.ERROR as LogSeverityLevelType;
+ attributes = {
+ orderId: "",
+ saleorApiUrl: "",
+ avataxTransactionId: undefined,
+ eventType: EventTypes.AVATAX_TRANSACTION_CREATION_FAILED,
+ } as PublicLog<{ orderId: string; avataxTransactionId?: string }>["attributes"];
+
+ constructor(params: { orderId: string; avataxTransactionId?: string; saleorApiUrl: string }) {
+ this.attributes.orderId = params.orderId;
+ this.attributes.saleorApiUrl = params.saleorApiUrl;
+
+ if (params.avataxTransactionId) {
+ this.attributes.avataxTransactionId = params.avataxTransactionId;
+ }
+ }
+}
+
+export class AvataxTransactionCreateFailedBadPayload extends AvataxTransactionCreationError {
+ message = "Avatax transaction creation failed due to bad payload";
+}
+
+export class AvataxTransactionCreateFailedUnhandledError extends AvataxTransactionCreationError {
+ message = "Avatax transaction creation failed due to unhandled error";
+ level = LogSeverityLevel.FATAL;
+}
+
+export class SaleorOrderConfirmedLog implements PublicLog<{ orderId: string }> {
+ message = "Saleor order was confirmed";
+ timestamp = new Date();
+ level = LogSeverityLevel.INFO;
+ attributes = {
+ orderId: "",
+ saleorApiUrl: "",
+ eventType: EventTypes.SALEOR_ORDER_CONFIRMED,
+ };
+
+ constructor(params: { orderId: string; saleorApiUrl: string }) {
+ this.attributes.orderId = params.orderId;
+ this.attributes.saleorApiUrl = params.saleorApiUrl;
+ }
+}
diff --git a/apps/avatax/src/modules/public-log-drain/public-log-drain.service.ts b/apps/avatax/src/modules/public-log-drain/public-log-drain.service.ts
new file mode 100644
index 000000000..cf56d74b1
--- /dev/null
+++ b/apps/avatax/src/modules/public-log-drain/public-log-drain.service.ts
@@ -0,0 +1,18 @@
+import { LogDrainTransporter, PublicLogDrain } from "./public-log-drain";
+import { PublicLog } from "./public-events";
+
+export class PublicLogDrainService implements PublicLogDrain {
+ constructor(private transporters: LogDrainTransporter[]) {}
+
+ addTransporter(transporter: LogDrainTransporter) {
+ this.transporters.push(transporter);
+ }
+
+ emitLog(log: PublicLog): Promise {
+ return Promise.all(this.transporters.map((t) => t.emit(log)));
+ }
+
+ getTransporters() {
+ return this.transporters;
+ }
+}
diff --git a/apps/avatax/src/modules/public-log-drain/public-log-drain.ts b/apps/avatax/src/modules/public-log-drain/public-log-drain.ts
new file mode 100644
index 000000000..6b799bb72
--- /dev/null
+++ b/apps/avatax/src/modules/public-log-drain/public-log-drain.ts
@@ -0,0 +1,11 @@
+import { PublicLog } from "./public-events";
+
+export interface PublicLogDrain {
+ emitLog(log: PublicLog): Promise;
+ getTransporters(): LogDrainTransporter[];
+ addTransporter(transporter: LogDrainTransporter): void;
+}
+
+export interface LogDrainTransporter {
+ emit(log: PublicLog): Promise;
+}
diff --git a/apps/avatax/src/modules/public-log-drain/transporters/public-log-drain-json-transporter.ts b/apps/avatax/src/modules/public-log-drain/transporters/public-log-drain-json-transporter.ts
new file mode 100644
index 000000000..34c370740
--- /dev/null
+++ b/apps/avatax/src/modules/public-log-drain/transporters/public-log-drain-json-transporter.ts
@@ -0,0 +1,62 @@
+import { LogDrainTransporter } from "../public-log-drain";
+import { PublicLog } from "../public-events";
+import { trace } from "@opentelemetry/api";
+import { createLogger } from "@saleor/apps-logger";
+import { ResultAsync, err, ok } from "neverthrow";
+import { BaseError } from "../../../error";
+
+export class LogDrainJsonTransporter implements LogDrainTransporter {
+ static TransporterError = BaseError.subclass("TransporterError");
+
+ static ConfigError = this.TransporterError.subclass("TransporterConfigError");
+ static FetchError = this.TransporterError.subclass("TransporterFetchError");
+
+ private endpoint: string | null = null;
+ private headers: Record = {};
+
+ async emit(log: PublicLog): Promise {
+ const logger = createLogger("LogDrainJsonTransporter.emit");
+
+ if (!this.endpoint) {
+ logger.error("Endpoint is not set, call setSettings first");
+ throw new LogDrainJsonTransporter.ConfigError("Endpoint is not set, call setSettings first");
+ }
+
+ const spanContext = trace.getActiveSpan()?.spanContext();
+
+ const payload = {
+ ...log,
+ traceId: spanContext?.traceId,
+ spanId: spanContext?.spanId,
+ isRemote: spanContext?.isRemote,
+ traceFlags: spanContext?.traceFlags,
+ traceState: spanContext?.traceState?.serialize(),
+ };
+
+ const result = await ResultAsync.fromPromise(
+ fetch(this.endpoint, {
+ method: "POST",
+ body: JSON.stringify(payload),
+ headers: this.headers,
+ }),
+ (err) =>
+ new LogDrainJsonTransporter.FetchError("Failed to make request to log drain", {
+ cause: err,
+ }),
+ ).andThen((response) =>
+ response.ok
+ ? ok(undefined)
+ : err(new LogDrainJsonTransporter.FetchError("Response from log drain is not HTTP 200")),
+ );
+
+ if (result.isErr()) {
+ // Silently ignore errors caused by making request to log drain
+ logger.debug("Error while making request to log drain");
+ }
+ }
+
+ setSettings({ endpoint, headers }: { endpoint: string; headers: Record }) {
+ this.endpoint = endpoint;
+ this.headers = headers;
+ }
+}
diff --git a/apps/avatax/src/modules/public-log-drain/transporters/public-log-drain-otel-transporter.ts b/apps/avatax/src/modules/public-log-drain/transporters/public-log-drain-otel-transporter.ts
new file mode 100644
index 000000000..cb45ef623
--- /dev/null
+++ b/apps/avatax/src/modules/public-log-drain/transporters/public-log-drain-otel-transporter.ts
@@ -0,0 +1,204 @@
+import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
+import { isAttributeValue, timeInputToHrTime } from "@opentelemetry/core";
+import { Attributes, trace } from "@opentelemetry/api";
+import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
+import packageJson from "../../../../package.json";
+import { IResource } from "@opentelemetry/resources";
+import { LogSeverityLevelType, PublicLog } from "../public-events";
+import { LogDrainTransporter } from "../public-log-drain";
+
+export interface LogRecordLimits {
+ /** attributeValueLengthLimit is maximum allowed attribute value size */
+ attributeValueLengthLimit?: number;
+
+ /** attributeCountLimit is number of attributes per LogRecord */
+ attributeCountLimit?: number;
+}
+
+export class LogDrainOtelTransporter implements LogDrainTransporter {
+ private otelExporter: OTLPLogExporter | null = null;
+ private logRecordLimit: Required = {
+ /*
+ * Default values used by OTEL spec
+ * https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#attribute-limits
+ */
+ attributeValueLengthLimit: Infinity,
+ attributeCountLimit: 128,
+ };
+
+ /*
+ * Maps seveity level to a matching number in OTEL specification range
+ * https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
+ */
+ private _mapSeverityToOtelNumber(severityLevel: LogSeverityLevelType) {
+ switch (severityLevel) {
+ case "TRACE":
+ return 1;
+ case "DEBUG":
+ return 5;
+ case "INFO":
+ return 9;
+ case "WARN":
+ return 13;
+ case "ERROR":
+ return 17;
+ case "FATAL":
+ return 21;
+ }
+ }
+
+ private _truncateSize(value: unknown) {
+ const limit = this.logRecordLimit.attributeValueLengthLimit;
+
+ const truncateToLimit = (value: string) => {
+ if (value.length <= limit) {
+ return value;
+ }
+ return value.substring(0, limit);
+ };
+
+ if (typeof value === "string") {
+ return truncateToLimit(value);
+ }
+
+ if (Array.isArray(value)) {
+ return (value as []).map((val) => (typeof val === "string" ? truncateToLimit(val) : val));
+ }
+
+ // If value is of another type, we can safely return it as is
+ return value;
+ }
+
+ private _filterAndTruncateAttributes(attributes: Record) {
+ /*
+ * We must filter out non-serializable values and truncate ones that exceed limits
+ * https://opentelemetry.io/docs/specs/otel/common/#attribute
+ */
+ const filteredAttributesEntries = Object.entries(attributes).filter(([key, value]) => {
+ if (value === null) {
+ return false;
+ }
+ if (key.length === 0) {
+ return false;
+ }
+ if (
+ !isAttributeValue(value) &&
+ !(typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0)
+ ) {
+ return false;
+ }
+ /*
+ * Additional validation that is missing in OTEL SDK, but causes crashes on otel-collector
+ * when sending non-finite numbers (e.g. NaN, Infinity)
+ */
+ if (typeof value === "number" && !Number.isFinite(value)) {
+ return false;
+ }
+
+ return true;
+ });
+
+ return filteredAttributesEntries
+ .slice(0, this.logRecordLimit.attributeCountLimit)
+ .reduce((acc, [key, value]) => {
+ if (Object.keys(acc).length >= this.logRecordLimit.attributeCountLimit) {
+ return acc;
+ }
+
+ if (isAttributeValue(value)) {
+ return {
+ ...acc,
+ [key]: this._truncateSize(value),
+ };
+ } else {
+ return {
+ ...acc,
+ [key]: value,
+ };
+ }
+ }, {});
+ }
+
+ constructor(settings?: { url: string; headers: Record }) {
+ if (!settings) {
+ return;
+ }
+
+ this.otelExporter = new OTLPLogExporter({
+ url: settings.url,
+ timeoutMillis: 2000,
+ headers: settings.headers,
+ });
+ }
+
+ async emit(log: PublicLog): Promise {
+ return new Promise((res, rej) => {
+ if (!this.otelExporter) {
+ throw new Error("Call setSettings first");
+ }
+
+ const spanContext = trace.getActiveSpan()?.spanContext();
+
+ const resourceAttributes: Attributes = {
+ [SemanticResourceAttributes.SERVICE_NAME]: "saleor-app-avatax",
+ [SemanticResourceAttributes.SERVICE_VERSION]: packageJson.version,
+ };
+
+ const resource: IResource = {
+ attributes: resourceAttributes,
+ // This is a workaround to support OTEL SDK types
+ merge(): IResource {
+ return this;
+ },
+ };
+
+ const attributes = this._filterAndTruncateAttributes(log.attributes);
+
+ return this.otelExporter.export(
+ [
+ {
+ body: log.message,
+ attributes,
+ severityText: log.level,
+ severityNumber: this._mapSeverityToOtelNumber(log.level),
+ hrTimeObserved: timeInputToHrTime(log.timestamp),
+ hrTime: timeInputToHrTime(log.timestamp),
+ spanContext,
+ resource,
+ droppedAttributesCount:
+ Object.keys(log.attributes).length - Object.keys(attributes).length,
+ instrumentationScope: { name: "LogDrainOtelTransporter" },
+ },
+ ],
+ (cb) => {
+ if (cb.error) {
+ rej(cb.error);
+ } else {
+ res();
+ }
+ },
+ );
+ });
+ }
+
+ setSettings(settings: {
+ url: string;
+ headers: Record;
+ logRecordLimit?: Required;
+ }) {
+ this.otelExporter = new OTLPLogExporter({
+ url: settings.url,
+ timeoutMillis: 2000,
+ headers: settings.headers,
+ });
+ if (settings.logRecordLimit) {
+ if (this.logRecordLimit.attributeValueLengthLimit <= 0) {
+ throw new Error("attributeValueLengthLimit cannot be less than 0");
+ }
+ if (this.logRecordLimit.attributeCountLimit <= 0) {
+ throw new Error("attributeCountLimit cannot be less than 0");
+ }
+ this.logRecordLimit = settings.logRecordLimit;
+ }
+ }
+}
diff --git a/apps/avatax/src/modules/taxes/avatax-webhook-service-factory.test.ts b/apps/avatax/src/modules/taxes/avatax-webhook-service-factory.test.ts
index 5afc7799b..a83e0a5e9 100644
--- a/apps/avatax/src/modules/taxes/avatax-webhook-service-factory.test.ts
+++ b/apps/avatax/src/modules/taxes/avatax-webhook-service-factory.test.ts
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
+import { AppConfig } from "../../lib/app-config";
import { ChannelsConfig } from "../channel-configuration/channel-config";
import { ProviderConnections } from "../provider-connections/provider-connections";
import { AvataxWebhookServiceFactory } from "./avatax-webhook-service-factory";
-import { AppConfig } from "../../lib/app-config";
const mockedProviders: ProviderConnections = [
{
@@ -26,6 +26,16 @@ const mockedProviders: ProviderConnections = [
street: "123 Main St",
zip: "10001",
},
+ logsSettings: {
+ otel: {
+ url: "https://otel.example.com",
+ headers: "Authorization",
+ },
+ json: {
+ url: "https://http.example.com",
+ headers: "Authorization",
+ },
+ },
},
},
];
diff --git a/apps/avatax/src/pages/api/webhooks/checkout-calculate-taxes.ts b/apps/avatax/src/pages/api/webhooks/checkout-calculate-taxes.ts
index a2ede64ee..0cb0f59c8 100644
--- a/apps/avatax/src/pages/api/webhooks/checkout-calculate-taxes.ts
+++ b/apps/avatax/src/pages/api/webhooks/checkout-calculate-taxes.ts
@@ -11,6 +11,7 @@ import { metadataCache, wrapWithMetadataCache } from "../../../lib/app-metadata-
import { SubscriptionPayloadErrorChecker } from "../../../lib/error-utils";
import { loggerContext } from "../../../logger-context";
import { CalculateTaxesUseCase } from "../../../modules/calculate-taxes/use-case/calculate-taxes.use-case";
+import { PublicLogDrainService } from "../../../modules/public-log-drain/public-log-drain.service";
import { AvataxInvalidAddressError } from "../../../modules/taxes/tax-error";
import { checkoutCalculateTaxesSyncWebhook } from "../../../modules/webhooks/definitions/checkout-calculate-taxes";
@@ -27,6 +28,7 @@ const withMetadataCache = wrapWithMetadataCache(metadataCache);
const subscriptionErrorChecker = new SubscriptionPayloadErrorChecker(logger, captureException);
const useCase = new CalculateTaxesUseCase({
configExtractor: new AppConfigExtractor(),
+ publicLogDrain: new PublicLogDrainService([]),
});
/**
diff --git a/apps/avatax/src/pages/api/webhooks/order-calculate-taxes.ts b/apps/avatax/src/pages/api/webhooks/order-calculate-taxes.ts
index eeb3a43e7..95cc6ee15 100644
--- a/apps/avatax/src/pages/api/webhooks/order-calculate-taxes.ts
+++ b/apps/avatax/src/pages/api/webhooks/order-calculate-taxes.ts
@@ -3,12 +3,22 @@ import { withOtel } from "@saleor/apps-otel";
import { ObservabilityAttributes } from "@saleor/apps-otel/src/lib/observability-attributes";
import * as Sentry from "@sentry/nextjs";
import { captureException } from "@sentry/nextjs";
+import { waitUntil } from "@vercel/functions";
import { AppConfigExtractor } from "../../../lib/app-config-extractor";
import { AppConfigurationLogger } from "../../../lib/app-configuration-logger";
import { metadataCache, wrapWithMetadataCache } from "../../../lib/app-metadata-cache";
import { SubscriptionPayloadErrorChecker } from "../../../lib/error-utils";
import { createLogger } from "../../../logger";
import { loggerContext } from "../../../logger-context";
+import {
+ TaxesCalculatedInOrderLog,
+ TaxesCalculationFailedConfigErrorLog,
+ TaxesCalculationFailedInvalidPayloadLog,
+ TaxesCalculationFailedUnhandledErrorLog,
+} from "../../../modules/public-log-drain/public-events";
+import { PublicLogDrainService } from "../../../modules/public-log-drain/public-log-drain.service";
+import { LogDrainJsonTransporter } from "../../../modules/public-log-drain/transporters/public-log-drain-json-transporter";
+import { LogDrainOtelTransporter } from "../../../modules/public-log-drain/transporters/public-log-drain-otel-transporter";
import { AvataxInvalidAddressError } from "../../../modules/taxes/tax-error";
import { orderCalculateTaxesSyncWebhook } from "../../../modules/webhooks/definitions/order-calculate-taxes";
import { verifyCalculateTaxesPayload } from "../../../modules/webhooks/validate-webhook-payload";
@@ -25,6 +35,11 @@ const withMetadataCache = wrapWithMetadataCache(metadataCache);
const subscriptionErrorChecker = new SubscriptionPayloadErrorChecker(logger, captureException);
+const otelLogDrainTransporter = new LogDrainOtelTransporter();
+const jsonLogDrainTransporter = new LogDrainJsonTransporter();
+
+const publicLoggerOtel = new PublicLogDrainService([]);
+
export default wrapWithLoggerContext(
withOtel(
withMetadataCache(
@@ -51,6 +66,16 @@ export default wrapWithLoggerContext(
error: payloadVerificationResult.error,
});
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new TaxesCalculationFailedInvalidPayloadLog({
+ additionalMessage: payloadVerificationResult.error.message,
+ orderId: payload.taxBase?.sourceObject.id,
+ saleorApiUrl: ctx.authData.saleorApiUrl,
+ }),
+ ),
+ );
+
return res.status(400).send(payloadVerificationResult.error.message);
}
@@ -81,6 +106,15 @@ export default wrapWithLoggerContext(
if (config.isErr()) {
logger.warn("Failed to extract app config from metadata", { error: config.error });
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new TaxesCalculationFailedConfigErrorLog({
+ orderId: payload.taxBase?.sourceObject.id,
+ saleorApiUrl: ctx.authData.saleorApiUrl,
+ }),
+ ),
+ );
+
return res.status(400).send("App configuration is broken");
}
@@ -100,9 +134,59 @@ export default wrapWithLoggerContext(
const providerConfig = config.value.getConfigForChannelSlug(channelSlug);
if (providerConfig.isErr()) {
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new TaxesCalculationFailedConfigErrorLog({
+ orderId: payload.taxBase?.sourceObject.id,
+ saleorApiUrl: ctx.authData.saleorApiUrl,
+ }),
+ ),
+ );
+
return res.status(400).send("App is not configured properly.");
}
+ if (providerConfig.value.avataxConfig.config.logsSettings?.otel.enabled) {
+ const url = providerConfig.value.avataxConfig.config.logsSettings.otel.url ?? "";
+
+ let headers: Record;
+
+ try {
+ headers = JSON.parse(
+ providerConfig.value.avataxConfig.config.logsSettings.otel.headers ?? "",
+ );
+ } catch {
+ headers = {};
+ }
+
+ otelLogDrainTransporter.setSettings({
+ headers,
+ url,
+ });
+
+ publicLoggerOtel.addTransporter(otelLogDrainTransporter);
+ }
+
+ if (providerConfig.value.avataxConfig.config.logsSettings?.json.enabled) {
+ const url = providerConfig.value.avataxConfig.config.logsSettings.json.url ?? "";
+
+ let headers: Record;
+
+ try {
+ headers = JSON.parse(
+ providerConfig.value.avataxConfig.config.logsSettings.json.headers ?? "",
+ );
+ } catch {
+ headers = {};
+ }
+
+ jsonLogDrainTransporter.setSettings({
+ endpoint: url,
+ headers,
+ });
+ publicLoggerOtel.addTransporter(jsonLogDrainTransporter);
+ }
+
const calculatedTaxes = await taxProvider.calculateTaxes(
payload,
providerConfig.value.avataxConfig.config,
@@ -111,25 +195,17 @@ export default wrapWithLoggerContext(
logger.info("Taxes calculated", { calculatedTaxes });
- return res.status(200).json(ctx.buildResponse(calculatedTaxes));
- } else if (avataxWebhookServiceResult.isErr()) {
- const err = avataxWebhookServiceResult.error;
-
- logger.warn(`Error in taxes calculation occurred: ${err.name} ${err.message}`, {
- error: err,
- });
-
- switch (err["constructor"]) {
- case AvataxWebhookServiceFactory.BrokenConfigurationError: {
- return res.status(400).send("App is not configured properly.");
- }
- default: {
- Sentry.captureException(avataxWebhookServiceResult.error);
- logger.fatal("Unhandled error", { error: err });
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new TaxesCalculatedInOrderLog({
+ orderId: payload.taxBase?.sourceObject.id,
+ saleorApiUrl: ctx.authData.saleorApiUrl,
+ }),
+ ),
+ );
- return res.status(500).send("Unhandled error");
- }
- }
+ return res.status(200).json(ctx.buildResponse(calculatedTaxes));
+ } else {
}
} catch (error) {
if (error instanceof AvataxInvalidAddressError) {
@@ -138,12 +214,30 @@ export default wrapWithLoggerContext(
{ error },
);
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new TaxesCalculationFailedConfigErrorLog({
+ additionalMessage: "Wrong address configuration",
+ orderId: ctx.payload.taxBase?.sourceObject.id,
+ saleorApiUrl: ctx.authData.saleorApiUrl,
+ }),
+ ),
+ );
+
return res.status(400).json({
message: "InvalidAppAddressError: Check address in app configuration",
});
}
Sentry.captureException(error);
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new TaxesCalculationFailedUnhandledErrorLog({
+ orderId: ctx.payload.taxBase?.sourceObject.id,
+ saleorApiUrl: ctx.authData.saleorApiUrl,
+ }),
+ ),
+ );
return res.status(500).send("Unhandled error");
}
diff --git a/apps/avatax/src/pages/api/webhooks/order-confirmed.ts b/apps/avatax/src/pages/api/webhooks/order-confirmed.ts
index 3f7a0d806..740822d0b 100644
--- a/apps/avatax/src/pages/api/webhooks/order-confirmed.ts
+++ b/apps/avatax/src/pages/api/webhooks/order-confirmed.ts
@@ -3,6 +3,7 @@ import { withOtel } from "@saleor/apps-otel";
import { ObservabilityAttributes } from "@saleor/apps-otel/src/lib/observability-attributes";
import * as Sentry from "@sentry/nextjs";
import { captureException } from "@sentry/nextjs";
+import { waitUntil } from "@vercel/functions";
import { AppConfigExtractor } from "../../../lib/app-config-extractor";
import { AppConfigurationLogger } from "../../../lib/app-configuration-logger";
import { metadataCache, wrapWithMetadataCache } from "../../../lib/app-metadata-cache";
@@ -11,6 +12,15 @@ import { SubscriptionPayloadErrorChecker } from "../../../lib/error-utils";
import { createLogger } from "../../../logger";
import { loggerContext } from "../../../logger-context";
import { OrderMetadataManager } from "../../../modules/app/order-metadata-manager";
+import {
+ AvataxTransactionCreateFailedBadPayload,
+ AvataxTransactionCreateFailedUnhandledError,
+ AvataxTransactionCreatedLog,
+ SaleorOrderConfirmedLog,
+} from "../../../modules/public-log-drain/public-events";
+import { PublicLogDrainService } from "../../../modules/public-log-drain/public-log-drain.service";
+import { LogDrainJsonTransporter } from "../../../modules/public-log-drain/transporters/public-log-drain-json-transporter";
+import { LogDrainOtelTransporter } from "../../../modules/public-log-drain/transporters/public-log-drain-otel-transporter";
import { SaleorOrderConfirmedEvent } from "../../../modules/saleor";
import { TaxBadPayloadError } from "../../../modules/taxes/tax-error";
import { orderConfirmedAsyncWebhook } from "../../../modules/webhooks/definitions/order-confirmed";
@@ -26,6 +36,11 @@ const logger = createLogger("orderConfirmedAsyncWebhook");
const withMetadataCache = wrapWithMetadataCache(metadataCache);
const subscriptionErrorChecker = new SubscriptionPayloadErrorChecker(logger, captureException);
+const otelLogDrainTransporter = new LogDrainOtelTransporter();
+const jsonLogDrainTransporter = new LogDrainJsonTransporter();
+
+const publicLoggerOtel = new PublicLogDrainService([]);
+
export default wrapWithLoggerContext(
withOtel(
withMetadataCache(
@@ -125,6 +140,45 @@ export default wrapWithLoggerContext(
return res.status(400).send("App is not configured properly.");
}
+ if (providerConfig.value.avataxConfig.config.logsSettings?.otel.enabled) {
+ let headers: Record;
+
+ try {
+ headers = JSON.parse(
+ providerConfig.value.avataxConfig.config.logsSettings.otel.headers ?? "",
+ );
+ } catch {
+ headers = {};
+ }
+ const url = providerConfig.value.avataxConfig.config.logsSettings.otel.url ?? "";
+
+ otelLogDrainTransporter.setSettings({
+ headers,
+ url,
+ });
+
+ publicLoggerOtel.addTransporter(otelLogDrainTransporter);
+ }
+
+ if (providerConfig.value.avataxConfig.config.logsSettings?.json.enabled) {
+ let headers: Record;
+
+ try {
+ headers = JSON.parse(
+ providerConfig.value.avataxConfig.config.logsSettings.json.headers ?? "",
+ );
+ } catch {
+ headers = {};
+ }
+ const url = providerConfig.value.avataxConfig.config.logsSettings.json.url ?? "";
+
+ jsonLogDrainTransporter.setSettings({
+ endpoint: url,
+ headers,
+ });
+ publicLoggerOtel.addTransporter(jsonLogDrainTransporter);
+ }
+
try {
const confirmedOrder = await taxProvider.confirmOrder(
// @ts-expect-error: OrderConfirmedSubscriptionFragment is deprecated
@@ -135,6 +189,16 @@ export default wrapWithLoggerContext(
);
logger.info("Order confirmed", { orderId: confirmedOrder.id });
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new AvataxTransactionCreatedLog({
+ orderId: confirmedOrderEvent.getOrderId(),
+ avataxTransactionId: confirmedOrder.id,
+ saleorApiUrl,
+ }),
+ ),
+ );
+
const client = createInstrumentedGraphqlClient({
saleorApiUrl,
token,
@@ -147,6 +211,14 @@ export default wrapWithLoggerContext(
confirmedOrder.id,
);
logger.info("Updated order metadata with externalId");
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new SaleorOrderConfirmedLog({
+ orderId: confirmedOrderEvent.getOrderId(),
+ saleorApiUrl,
+ }),
+ ),
+ );
return res.status(200).end();
} catch (error) {
@@ -154,31 +226,30 @@ export default wrapWithLoggerContext(
switch (true) {
case error instanceof TaxBadPayloadError: {
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new AvataxTransactionCreateFailedBadPayload({
+ orderId: confirmedOrderEvent.getOrderId(),
+ saleorApiUrl,
+ }),
+ ),
+ );
return res.status(400).send("Order data is not valid.");
}
}
Sentry.captureException(error);
logger.error("Unhandled error executing webhook", { error });
- return res.status(500).send("Unhandled error");
- }
- }
-
- if (webhookServiceResult.isErr()) {
- const error = webhookServiceResult.error;
-
- logger.debug("Error confirming order", { error });
-
- switch (error["constructor"]) {
- case AvataxWebhookServiceFactory.BrokenConfigurationError: {
- return res.status(400).send("App is not configured properly.");
- }
- default: {
- Sentry.captureException(webhookServiceResult.error);
- logger.fatal("Unhandled error", { error });
+ waitUntil(
+ publicLoggerOtel.emitLog(
+ new AvataxTransactionCreateFailedUnhandledError({
+ orderId: confirmedOrderEvent.getOrderId(),
+ saleorApiUrl,
+ }),
+ ),
+ );
- return res.status(500).send("Unhandled error");
- }
+ return res.status(500).send("Unhandled error");
}
}
} catch (error) {
diff --git a/apps/avatax/src/pages/configuration.tsx b/apps/avatax/src/pages/configuration.tsx
index e5b899b4c..53b428c07 100644
--- a/apps/avatax/src/pages/configuration.tsx
+++ b/apps/avatax/src/pages/configuration.tsx
@@ -1,10 +1,10 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
+import { Text } from "@saleor/macaw-ui";
import { ChannelSection } from "../modules/channel-configuration/ui/channel-section";
import { ProvidersSection } from "../modules/provider-connections/ui/providers-section";
import { AppPageLayout } from "../modules/ui/app-page-layout";
import { Section } from "../modules/ui/app-section";
import { MatcherSection } from "../modules/ui/matcher-section";
-import { Text } from "@saleor/macaw-ui";
const Header = () => {
return (
diff --git a/packages/otel/src/otel-logs-setup.ts b/packages/otel/src/otel-logs-setup.ts
index 7685cfb3c..0a9e954f9 100644
--- a/packages/otel/src/otel-logs-setup.ts
+++ b/packages/otel/src/otel-logs-setup.ts
@@ -1,5 +1,9 @@
import { logs } from "@opentelemetry/api-logs";
-import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
+import {
+ BatchLogRecordProcessor,
+ LoggerProvider,
+ SimpleLogRecordProcessor,
+} from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import {
detectResourcesSync,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index be5e0e22a..d605751c2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -101,13 +101,13 @@ importers:
version: 13.5.4
'@opentelemetry/api':
specifier: ../../node_modules/@opentelemetry/api
- version: link:../../node_modules/@opentelemetry/api
+ version: 1.8.0
'@opentelemetry/api-logs':
specifier: ../../node_modules/@opentelemetry/api-logs
version: link:../../node_modules/@opentelemetry/api-logs
'@opentelemetry/core':
specifier: ../../node_modules/@opentelemetry/core
- version: link:../../node_modules/@opentelemetry/core
+ version: 1.24.1(@opentelemetry/api@1.8.0)
'@opentelemetry/exporter-logs-otlp-http':
specifier: ../../node_modules/@opentelemetry/exporter-logs-otlp-http
version: link:../../node_modules/@opentelemetry/exporter-logs-otlp-http
@@ -131,13 +131,13 @@ importers:
version: link:../../node_modules/@opentelemetry/sdk-node
'@opentelemetry/sdk-trace-base':
specifier: ../../node_modules/@opentelemetry/sdk-trace-base
- version: link:../../node_modules/@opentelemetry/sdk-trace-base
+ version: 1.24.1(@opentelemetry/api@1.8.0)
'@opentelemetry/sdk-trace-node':
specifier: ../../node_modules/@opentelemetry/sdk-trace-node
version: link:../../node_modules/@opentelemetry/sdk-trace-node
'@opentelemetry/semantic-conventions':
specifier: ../../node_modules/@opentelemetry/semantic-conventions
- version: link:../../node_modules/@opentelemetry/semantic-conventions
+ version: 1.24.1
'@saleor/app-sdk':
specifier: link:../../node_modules/@saleor/app-sdk
version: link:../../node_modules/@saleor/app-sdk
@@ -167,7 +167,7 @@ importers:
version: 2.31.2
'@sentry/nextjs':
specifier: 8.0.0
- version: 8.0.0(@opentelemetry/api@node_modules+@opentelemetry+api)(@opentelemetry/core@node_modules+@opentelemetry+core)(@opentelemetry/instrumentation@0.51.1)(@opentelemetry/sdk-trace-base@node_modules+@opentelemetry+sdk-trace-base)(@opentelemetry/semantic-conventions@node_modules+@opentelemetry+semantic-conventions)(next@14.2.3)(react@18.2.0)(webpack@5.82.1)
+ version: 8.0.0(@opentelemetry/api@1.8.0)(@opentelemetry/core@1.24.1)(@opentelemetry/instrumentation@0.51.1)(@opentelemetry/sdk-trace-base@1.24.1)(@opentelemetry/semantic-conventions@1.24.1)(next@14.2.3)(react@18.2.0)(webpack@5.82.1)
'@tanstack/react-query':
specifier: 4.29.19
version: 4.29.19(react-dom@18.2.0)(react@18.2.0)
@@ -186,6 +186,9 @@ importers:
'@urql/exchange-auth':
specifier: ^2.1.4
version: 2.1.4(graphql@16.7.1)
+ '@vercel/functions':
+ specifier: 1.0.2
+ version: 1.0.2
avatax:
specifier: ^23.7.0
version: 23.7.0
@@ -215,7 +218,7 @@ importers:
version: 6.1.0
next:
specifier: 14.2.3
- version: 14.2.3(@babel/core@7.24.3)(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0)(react@18.2.0)
+ version: 14.2.3(@babel/core@7.24.3)(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
pino:
specifier: ^8.14.1
version: 8.14.1
@@ -12255,7 +12258,6 @@ packages:
- '@opentelemetry/semantic-conventions'
- encoding
- supports-color
- dev: true
/@sentry/nextjs@8.0.0(@opentelemetry/api@node_modules+@opentelemetry+api)(@opentelemetry/core@node_modules+@opentelemetry+core)(@opentelemetry/instrumentation@0.51.1)(@opentelemetry/sdk-trace-base@node_modules+@opentelemetry+sdk-trace-base)(@opentelemetry/semantic-conventions@node_modules+@opentelemetry+semantic-conventions)(next@14.2.3)(react@18.2.0)(webpack@5.82.1):
resolution: {integrity: sha512-FJEW0w1WJHMV9NWHZHOXNQPOSg9pxjAq5y7XcP88rCqa08bmVUNVyg5UbHKBMXG1BjZukVr/8in/4sbxBOv3VQ==}
@@ -13518,7 +13520,7 @@ packages:
'@trpc/client': 10.43.1(@trpc/server@10.43.1)
'@trpc/react-query': 10.43.1(@tanstack/react-query@4.29.19)(@trpc/client@10.43.1)(@trpc/server@10.43.1)(react-dom@18.2.0)(react@18.2.0)
'@trpc/server': 10.43.1
- next: 14.2.3(@babel/core@7.24.3)(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0)(react@18.2.0)
+ next: 14.2.3(@babel/core@7.24.3)(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-ssr-prepass: 1.5.0(react@18.2.0)
@@ -21978,7 +21980,6 @@ packages:
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
- dev: true
/next@14.2.3(@babel/core@7.24.3)(@opentelemetry/api@node_modules+@opentelemetry+api)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==}