Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/pwa-kit-react-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## v3.11.0-dev.0 (May 23, 2025)
- Fix the performance logging so that it'll capture all SSR queries, even those that result in errors [#2486](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2486)
- Created an opentelemetry server for SSR tracer intialization [#2617](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2617)
- Created opentelemetry.js file with utility functions to log OTel spans and metrics [#2705] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2705)

## v3.10.0 (May 22, 2025)
- Fix the performance logging util to use the correct delimiter for the server-timing header. [#2225](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2295)
Expand Down
2 changes: 1 addition & 1 deletion packages/pwa-kit-react-sdk/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
/* eslint-disable @typescript-eslint/no-var-requires */
const base = require('internal-lib-build/configs/jest/jest.config')
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: Do we need this change?

Copy link
Collaborator

Choose a reason for hiding this comment

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

+1 on this nit. It may not seem like a big deal that a change like this that doesn't do anything goes in the PR, but one downside is in the future if someone wants to do a git blame to see "hey who added this line about requiring jest.config here and why did they do that" then they will see this PR instead of the originating PR.


const base = require('internal-lib-build/configs/jest/jest.config')
module.exports = {
...base,
setupFilesAfterEnv: ['./setup-jest.js'],
Expand Down
1 change: 1 addition & 0 deletions packages/pwa-kit-react-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@loadable/server": "^5.15.3",
"@loadable/webpack-plugin": "^5.15.2",
"@opentelemetry/api": "^1.4.1",
"@opentelemetry/core": "^1.15.1",
"@opentelemetry/propagator-b3": "^1.15.1",
"@opentelemetry/resources": "^1.15.1",
"@opentelemetry/sdk-trace-base": "^1.15.1",
Expand Down
337 changes: 337 additions & 0 deletions packages/pwa-kit-react-sdk/src/utils/opentelemetry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import {trace, context, SpanStatusCode} from '@opentelemetry/api'
import {hrTimeToMilliseconds, hrTimeToTimeStamp} from '@opentelemetry/core'
import logger from './logger-instance'

const DEFAULT_SERVICE_NAME = 'pwa-kit-react-sdk'

export const OTEL_CONFIG = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's use the OTEL_CONFIG in packages/pwa-kit-react-sdk/src/utils/opentelemetry-server.js file.

Remove the DEFAULT_SERVICE_NAME constant from opentelemetry-server.js:

const DEFAULT_SERVICE_NAME = 'pwa-kit-react-sdk'

And replace it with getServiceName:

import {getServiceName, OTEL_CONFIG} from '../../utils/opentelemetry'

const serviceName = options.serviceName || getServiceName()
...

serviceName: process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME,
enabled: process.env.OTEL_SDK_ENABLED === 'true',
Copy link
Collaborator

Choose a reason for hiding this comment

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

I see we have an enabled flag in the CONFIG but are we reading this in anywhere? Or will this be used in future PR's when we make changes to performance and react-rendering?

b3TracingEnabled: process.env.OTEL_B3_TRACING_ENABLED === 'true'
}

export const getServiceName = () => OTEL_CONFIG.serviceName

const SERVICE_NAME = OTEL_CONFIG.serviceName
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const SERVICE_NAME = OTEL_CONFIG.serviceName

We don't need this line. As Adam mentioned, we can replace all instances of SERVICE_NAME with getServiceName()


const logSpanData = (span, event = 'start', res = null) => {
const spanContext = span.spanContext()
const startTime = span.startTime
const endTime = event === 'start' ? startTime : span.endTime
const duration = event === 'start' ? 0 : hrTimeToMilliseconds(span.duration)

// Create the span data object that matches the expected format
const spanData = {
traceId: spanContext.traceId,
parentId: span.parentSpanId,
name: span.name,
id: spanContext.spanId,
kind: span.kind,
timestamp: hrTimeToTimeStamp(startTime),
duration: duration,
attributes: {
'service.name': SERVICE_NAME,
Copy link
Contributor

Choose a reason for hiding this comment

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

If you agree with the previous comment we need to replace all references of SERVICE_NAME:

Suggested change
'service.name': SERVICE_NAME,
'service.name': getServiceName(),

...span.attributes,
event: event // Add event type to distinguish start/end
},
status: {code: event === 'start' ? SpanStatusCode.UNSET : SpanStatusCode.OK},
events: [],
links: [],
start_time: startTime,
end_time: endTime,
forwardTrace: OTEL_CONFIG.b3TracingEnabled
}

// Inject B3 headers into response if available
if (res && process.env.DISABLE_B3_TRACING !== 'true' && event === 'start') {
res.setHeader('x-b3-traceid', spanContext.traceId)
res.setHeader('x-b3-spanid', spanContext.spanId)
res.setHeader('x-b3-sampled', '1')

if (span.parentSpanId) {
res.setHeader('x-b3-parentspanid', span.parentSpanId)
}
}

// Only log if this is an end event or if it's a start event for a new span
if (event === 'end' || !Object.prototype.hasOwnProperty.call(span.attributes, 'event')) {
logger.info('OpenTelemetry span data', {
namespace: 'opentelemetry.logSpanData',
additionalProperties: spanData
})
}
}

/**
* Creates a new span with the given name and options
* @param {string} name - The name of the span
* @param {Object} options - Span options
* @returns {Span} The created span
*/
export const createSpan = (name, options = {}) => {
try {
const tracer = trace.getTracer(SERVICE_NAME)
Copy link
Collaborator

Choose a reason for hiding this comment

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

For example, we would replace it here:

Suggested change
const tracer = trace.getTracer(SERVICE_NAME)
const tracer = trace.getTracer(getServiceName())

// Get the current context and active span
const ctx = context.active()
// Note: currentSpan is not used in this implementation

// Create a new span with the current context
const span = tracer.startSpan(
name,
{
...options,
attributes: {
...options.attributes,
'service.name': SERVICE_NAME
}
},
ctx
)

// Set the new span as active
logSpanData(span, 'start')
return trace.setSpan(ctx, span)
} catch (error) {
logger.error('Failed to create span', {
namespace: 'opentelemetry',
additionalProperties: {
spanName: name,
error: error.message
}
})
return null
}
}

/**
* Creates a child span with the given name and attributes
* @param {string} name - The name of the span
* @param {Object} attributes - The attributes to add to the span
* @returns {Span} The created span
*/
export const createChildSpan = (name, attributes = {}) => {
try {
const tracer = trace.getTracer(SERVICE_NAME)
const ctx = context.active()
const parentSpan = trace.getSpan(ctx)

// Don't create duplicate spans
if (parentSpan?.attributes?.performance_mark === name) {
return parentSpan
}

const {performance_mark, performance_detail, ...otherAttributes} = attributes

const spanAttributes = {
'service.name': SERVICE_NAME,
...otherAttributes
}

if (performance_mark) {
spanAttributes['performance.mark'] = performance_mark
spanAttributes['performance.type'] = 'start'
spanAttributes['performance.detail'] =
typeof performance_detail === 'string'
? performance_detail
: JSON.stringify(performance_detail)
}

const span = tracer.startSpan(
name,
{
attributes: spanAttributes
},
parentSpan ? ctx : undefined
)

logSpanData(span, 'start')
return span
} catch (error) {
logger.error('Error creating OpenTelemetry span', {
namespace: 'opentelemetry',
additionalProperties: {
spanName: name,
error: error.message,
stack: error.stack
}
})
return null
}
}

/**
* Ends a span and logs its data
* @param {Span} span - The span to end
*/
export const endSpan = (span) => {
if (!span) {
return
}

try {
const ctx = context.active()
// Note: parentSpan is not used in this implementation
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need these 2 lines if ctx is not used anywhere?


span.end()

// Log completion data
logSpanData(span, 'end')
} catch (error) {
logger.error('Error ending OpenTelemetry span', {
namespace: 'opentelemetry',
additionalProperties: {
error: error.message,
stack: error.stack
}
})
}
}

/**
* Creates a span for performance measurement
* @param {string} name - The name of the performance span
* @param {Function} fn - The function to measure
* @param {Object} res - The response object (optional)
* @returns {Promise<any>} The result of the function
*/
export const tracePerformance = async (name, fn, res = null) => {
const tracer = trace.getTracer(SERVICE_NAME)
// Create the root span
const rootSpan = tracer.startSpan(name, {
attributes: {
'service.name': SERVICE_NAME
}
})

// Create a new context with the root span
const ctx = trace.setSpan(context.active(), rootSpan)

// Log start event
logSpanData(rootSpan, 'start', res)

try {
// Run the function within the context of the root span
const result = await context.with(ctx, async () => {
try {
return await fn()
} catch (error) {
rootSpan.setStatus({
code: SpanStatusCode.ERROR,
message: error.message
})
throw error
}
})

rootSpan.end()

// Log completion data
logSpanData(rootSpan, 'end', res)

return result
} catch (error) {
rootSpan.end()

// Log error completion
logSpanData(rootSpan, 'end', res)

throw error
}
}

/**
* Traces a performance metric
* @param {string} name - The name of the metric
* @param {number} duration - The duration of the metric in milliseconds
* @param {Object} attributes - Additional attributes for the metric
*/
export const logPerformanceMetric = (name, duration, attributes = {}) => {
try {
const tracer = trace.getTracer(SERVICE_NAME)
const ctx = context.active()
const parentSpan = trace.getSpan(ctx)

if (!parentSpan) {
logger.warn('No parent span found in context', {
namespace: 'opentelemetry',
additionalProperties: {metricName: name}
})
return
}

// Extract and normalize performance details
const {performance_mark, performance_detail, ...otherAttributes} = attributes

// Build metric attributes
const metricAttributes = {
'service.name': SERVICE_NAME,
'metric.duration': duration,
...otherAttributes
}

if (performance_mark) {
metricAttributes['performance.mark'] = performance_mark
metricAttributes['performance.type'] = 'end'
metricAttributes['performance.detail'] =
typeof performance_detail === 'string'
? performance_detail
: JSON.stringify(performance_detail)
}

// Create and immediately end the metric span
const span = tracer.startSpan(
name,
{
attributes: metricAttributes
},
ctx
)

span.end()

// Log completion data
logSpanData(span, 'end')
} catch (error) {
logger.error('Error logging performance metric', {
namespace: 'opentelemetry',
additionalProperties: {
metricName: name,
error: error.message,
stack: error.stack
}
})
}
}

/**
* Traces a performance operation
* @param {string} name - The name of the operation
* @param {Function} fn - The function to trace
* @returns {Promise<any>} The result of the function
*/
export const traceChildPerformance = async (name, fn) => {
const span = createChildSpan(name)
if (!span) {
return fn()
}

try {
const result = await fn()
endSpan(span)
return result
} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message
})
endSpan(span)
throw error
}
}
Loading
Loading