Skip to content

Commit 83365a0

Browse files
authored
Merge pull request #512 from bugsnag/release/v2.10.0
Release v2.10.0
2 parents 623bab6 + ffd23ce commit 83365a0

31 files changed

+650
-68
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## [v2.10.0] (2024-09-26)
4+
5+
### Added
6+
7+
- Allow setting custom span attributes [#510](https://github.com/bugsnag/bugsnag-js-performance/pull/510)
8+
39
## [v2.9.1] (2024-09-11)
410

511
### Fixed

CONTRIBUTING.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Contributing
2+
3+
Thanks for stopping by! This document should cover most topics surrounding contributing to this repo.
4+
5+
* [How to contribute](#how-to-contribute)
6+
* [Reporting issues](#reporting-issues)
7+
* [Fixing issues](#fixing-issues)
8+
* [Adding features](#adding-features)
9+
10+
## Reporting issues
11+
Are you having trouble getting started? Please [contact us directly](mailto:support@bugsnag.com?subject=%5BGitHub%5D%20bugsnag-js-performance%20-%20having%20trouble%20getting%20started%20with%20BugSnag) for assistance with integrating BugSnag into your application.
12+
If you have spotted a problem with this module, feel free to open a [new issue](https://github.com/bugsnag/bugsnag-js-performance/issues/new?template=Bug_report.md). Here are a few things to check before doing so:
13+
14+
* Are you using the latest version of BugSnag? If not, does updating to the latest version fix your issue?
15+
* Has somebody else [already reported](https://github.com/bugsnag/bugsnag-js-performance/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen) your issue? Feel free to add additional context to or check-in on an existing issue that matches your own.
16+
* Is your issue caused by this module? Only things related to `@bugsnag/browser-performance`, `@bugsnag/react-native-performance`, and performance related plugins (such as `@bugsnag/plugin-react-navigation-performance`) should be reported here. For anything else, please [contact us directly](mailto:support@bugsnag.com) and we'd be happy to help you out.
17+
18+
### Fixing issues
19+
20+
If you've identified a fix to a new or existing issue, we welcome contributions!
21+
Here are some helpful suggestions on contributing that help us merge your PR quickly and smoothly:
22+
23+
* [Fork](https://help.github.com/articles/fork-a-repo) the
24+
[library on GitHub](https://github.com/bugsnag/bugsnag-js-performance)
25+
* Build and test your changes. You can use `npm pack` to build the module locally and install it in a real app.
26+
* Commit and push until you are happy with your contribution
27+
* [Make a pull request](https://help.github.com/articles/using-pull-requests)
28+
29+
### Adding features
30+
31+
Unfortunately we’re unable to accept PRs that add features or refactor the library at this time.
32+
However, we’re very eager and welcome to hearing feedback about the library so please contact us directly to discuss your idea, or open a
33+
[feature request](https://github.com/bugsnag/bugsnag-js-performance/issues/new?template=Feature_request.md) to help us improve the library.
34+
35+
Here’s a bit about our process designing and building the BugSnag libraries:
36+
37+
* We have an internal roadmap to plan out the features we build, and sometimes we will already be planning your suggested feature!
38+
* Our open source libraries span many languages and frameworks so we strive to ensure they are idiomatic on the given platform, but also consistent in terminology between platforms. That way the core concepts are familiar whether you adopt BugSnag for one platform or many.
39+
* Finally, one of our goals is to ensure our libraries work reliably, even in crashy, multi-threaded environments. Oftentimes, this requires an intensive engineering design and code review process that adheres to our style and linting guidelines.

bin/generate-react-native-fixture

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const PACKAGE_DIRECTORIES = [
5050
]
5151

5252
// make sure we install a compatible versions of peer dependencies
53-
const reactNativeFileAccessVersion = parseFloat(reactNativeVersion) <= 0.64 ? '1.7.1' : '3.1.0'
53+
const reactNativeFileAccessVersion = parseFloat(reactNativeVersion) <= 0.64 ? '1.7.1' : '3.1.1'
5454
const netinfoVersion = parseFloat(reactNativeVersion) <= 0.64 ? '10.0.0' : '11.3.2'
5555
const DEPENDENCIES = [
5656
`@bugsnag/react-native@${process.env.NOTIFIER_VERSION}`,
@@ -110,15 +110,6 @@ if (!process.env.SKIP_GENERATE_FIXTURE) {
110110
configureRN064Fixture(fixtureDir)
111111
}
112112

113-
// due to a bug in react-native-file-access, static frameworks is required on 0.75+ othwerwise iOS builds will fail.
114-
// there is an open PR to fix this upstream: https://github.com/alpha0010/react-native-file-access/pull/88
115-
// TODO: remove this when either the upstream PR is released or PLAT-12626 (replace react-native-file-access with our own File I/O) is implemented
116-
if (parseFloat(reactNativeVersion) >= 0.75) {
117-
let podfileContents = fs.readFileSync(`${fixtureDir}/ios/Podfile`, 'utf8')
118-
podfileContents = podfileContents.replace(/target 'reactnative' do/, 'use_frameworks! :linkage => :static\ntarget \'reactnative\' do')
119-
fs.writeFileSync(`${fixtureDir}/ios/Podfile`, podfileContents)
120-
}
121-
122113
// link react-native-navigation using rnn-link tool
123114
if (process.env.REACT_NATIVE_NAVIGATION === 'true' || process.env.REACT_NATIVE_NAVIGATION === '1') {
124115
execSync('npx rnn-link', { cwd: fixtureDir, stdio: 'inherit' })

packages/core/lib/attributes.ts

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,106 @@
1-
import type { Configuration, InternalConfiguration } from './config'
1+
import type { Configuration, InternalConfiguration, Logger } from './config'
2+
import { ATTRIBUTE_KEY_LENGTH_LIMIT, defaultResourceAttributeLimits } from './custom-attribute-limits'
23
import type { SpanInternal } from './span'
34
import { isNumber } from './validation'
45

5-
export type SpanAttribute = string | number | boolean
6+
interface StringAttributeValue { stringValue: string }
7+
interface IntAttributeValue { intValue: string }
8+
interface DoubleAttributeValue { doubleValue: number }
9+
interface BoolAttributeValue { boolValue: boolean }
10+
11+
type SingleAttributeValue = StringAttributeValue | IntAttributeValue | DoubleAttributeValue | BoolAttributeValue
12+
13+
interface ArrayAttributeValue {
14+
arrayValue: {
15+
values?: SingleAttributeValue[]
16+
}
17+
}
18+
19+
type JsonAttributeValue = SingleAttributeValue | ArrayAttributeValue
20+
21+
export interface JsonAttribute {
22+
key: string
23+
value: JsonAttributeValue
24+
}
25+
26+
type Attribute = string | number | boolean
27+
28+
// Array values should always be of the same type, although the trace server will accept mixed types
29+
type ArrayAttribute = string[] | number[] | boolean[]
30+
31+
export type SpanAttribute = Attribute | ArrayAttribute
632

733
export interface SpanAttributesSource <C extends Configuration> {
834
configure: (configuration: InternalConfiguration<C>) => void
935
requestAttributes: (span: SpanInternal) => void
1036
}
1137

38+
export interface SpanAttributesLimits {
39+
attributeStringValueLimit: number
40+
attributeArrayLengthLimit: number
41+
attributeCountLimit: number
42+
}
43+
44+
function truncateString (value: string, limit: number) {
45+
const originalLength = value.length
46+
const newString = value.slice(0, limit)
47+
const truncatedLength = newString.length
48+
49+
return `${newString} *** ${originalLength - truncatedLength} CHARS TRUNCATED`
50+
}
51+
1252
export class SpanAttributes {
1353
private readonly attributes: Map<string, SpanAttribute>
54+
private readonly logger: Logger
55+
private readonly spanAttributeLimits: SpanAttributesLimits
56+
private readonly spanName: string
57+
private _droppedAttributesCount = 0
58+
59+
get droppedAttributesCount () {
60+
return this._droppedAttributesCount
61+
}
1462

15-
constructor (initialValues: Map<string, SpanAttribute>) {
63+
constructor (initialValues: Map<string, SpanAttribute>, spanAttributeLimits: SpanAttributesLimits, spanName: string, logger: Logger) {
1664
this.attributes = initialValues
65+
this.spanAttributeLimits = spanAttributeLimits
66+
this.spanName = spanName
67+
this.logger = logger
68+
}
69+
70+
private validateAttribute (name: string, value: SpanAttribute) {
71+
if (typeof value === 'string' && value.length > this.spanAttributeLimits.attributeStringValueLimit) {
72+
this.attributes.set(name, truncateString(value, this.spanAttributeLimits.attributeStringValueLimit))
73+
this.logger.warn(`Span attribute ${name} in span ${this.spanName} was truncated as the string exceeds the ${this.spanAttributeLimits.attributeStringValueLimit} character limit set by attributeStringValueLimit.`)
74+
}
75+
76+
if (Array.isArray(value) && value.length > this.spanAttributeLimits.attributeArrayLengthLimit) {
77+
const truncatedValue = value.slice(0, this.spanAttributeLimits.attributeArrayLengthLimit)
78+
this.attributes.set(name, truncatedValue)
79+
this.logger.warn(`Span attribute ${name} in span ${this.spanName} was truncated as the array exceeds the ${this.spanAttributeLimits.attributeArrayLengthLimit} element limit set by attributeArrayLengthLimit.`)
80+
}
1781
}
1882

1983
set (name: string, value: SpanAttribute) {
20-
if (typeof value === 'string' || typeof value === 'boolean' || isNumber(value)) {
84+
if (typeof name === 'string' && (typeof value === 'string' || typeof value === 'boolean' || isNumber(value) || Array.isArray(value))) {
85+
this.attributes.set(name, value)
86+
}
87+
}
88+
89+
// Used by the public API to set custom attributes
90+
setCustom (name: string, value: SpanAttribute) {
91+
if (typeof name === 'string' && (typeof value === 'string' || typeof value === 'boolean' || isNumber(value) || Array.isArray(value))) {
92+
if (!this.attributes.has(name) && this.attributes.size >= this.spanAttributeLimits.attributeCountLimit) {
93+
this._droppedAttributesCount++
94+
this.logger.warn(`Span attribute ${name} in span ${this.spanName} was dropped as the number of attributes exceeds the ${this.spanAttributeLimits.attributeCountLimit} attribute limit set by attributeCountLimit.`)
95+
return
96+
}
97+
98+
if (name.length > ATTRIBUTE_KEY_LENGTH_LIMIT) {
99+
this._droppedAttributesCount++
100+
this.logger.warn(`Span attribute ${name} in span ${this.spanName} was dropped as the key length exceeds the ${ATTRIBUTE_KEY_LENGTH_LIMIT} character fixed limit.`)
101+
return
102+
}
103+
21104
this.attributes.set(name, value)
22105
}
23106
}
@@ -27,12 +110,13 @@ export class SpanAttributes {
27110
}
28111

29112
toJson () {
113+
Array.from(this.attributes).forEach(([key, value]) => { this.validateAttribute(key, value) })
30114
return Array.from(this.attributes).map(([key, value]) => attributeToJson(key, value))
31115
}
32116
}
33117

34118
export class ResourceAttributes extends SpanAttributes {
35-
constructor (releaseStage: string, appVersion: string, serviceName: string, sdkName: string, sdkVersion: string) {
119+
constructor (releaseStage: string, appVersion: string, serviceName: string, sdkName: string, sdkVersion: string, logger: Logger) {
36120
const initialValues = new Map([
37121
['deployment.environment', releaseStage],
38122
['telemetry.sdk.name', sdkName],
@@ -44,21 +128,49 @@ export class ResourceAttributes extends SpanAttributes {
44128
initialValues.set('service.version', appVersion)
45129
}
46130

47-
super(initialValues)
131+
// TODO: this class should be refactored to use a common base class instead of SpanAttributes
132+
// since we don't need a span name and logger for resource attributes - see PLAT-12820
133+
super(initialValues, defaultResourceAttributeLimits, 'resource-attributes', logger)
48134
}
49135
}
50136

51137
export type ResourceAttributeSource<C extends Configuration>
52138
= (configuration: InternalConfiguration<C>) => Promise<ResourceAttributes>
53139

54-
export interface JsonAttribute {
55-
key: string
56-
value: { stringValue: string }
57-
| { intValue: string }
58-
| { doubleValue: number }
59-
| { boolValue: boolean }
140+
function getJsonAttributeValue (value: Attribute): SingleAttributeValue | undefined {
141+
switch (typeof value) {
142+
case 'number':
143+
if (Number.isNaN(value) || !Number.isFinite(value)) {
144+
return undefined
145+
}
146+
147+
if (Number.isInteger(value)) {
148+
return { intValue: `${value}` }
149+
}
150+
151+
return { doubleValue: value }
152+
case 'boolean':
153+
return { boolValue: value }
154+
case 'string':
155+
return { stringValue: value }
156+
default:
157+
// Ensure all JsonAttributeValue cases are handled
158+
value satisfies never
159+
}
160+
}
161+
162+
function getJsonArrayAttributeValue (attributeArray: Attribute[]): SingleAttributeValue[] {
163+
return attributeArray
164+
.map((value) => getJsonAttributeValue(value))
165+
.filter(value => typeof value !== 'undefined')
60166
}
61167

168+
/**
169+
* Converts a span attribute into an OTEL compliant value i.e. { stringValue: 'value' }
170+
* @param key the name of the span attribute
171+
* @param attribute the value of the attribute. Can be of type string | number | boolean | string[] | number[] | boolean[]. Invalid types will be removed from array attributes.
172+
* @returns
173+
*/
62174
export function attributeToJson (key: string, attribute: SpanAttribute): JsonAttribute | undefined {
63175
switch (typeof attribute) {
64176
case 'number':
@@ -76,6 +188,12 @@ export function attributeToJson (key: string, attribute: SpanAttribute): JsonAtt
76188
return { key, value: { boolValue: attribute } }
77189
case 'string':
78190
return { key, value: { stringValue: attribute } }
191+
case 'object':
192+
if (Array.isArray(attribute)) {
193+
const arrayValues = getJsonArrayAttributeValue(attribute)
194+
return { key, value: { arrayValue: arrayValues.length > 0 ? { values: arrayValues } : {} } }
195+
}
196+
return undefined
79197
default:
80198
return undefined
81199
}

packages/core/lib/batch-processor.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import type ProbabilityManager from './probability-manager'
44
import type { Processor } from './processor'
55
import type { RetryQueue } from './retry-queue'
66
import type { ReadonlySampler } from './sampler'
7-
import type { SpanEnded } from './span'
7+
import type { Span, SpanEnded } from './span'
8+
9+
import { millisecondsToNanoseconds } from './clock'
10+
import { spanEndedToSpan } from './span'
11+
12+
export type OnSpanEndCallback = (span: Span) => boolean | Promise<boolean>
13+
export type OnSpanEndCallbacks = OnSpanEndCallback[]
814

915
type MinimalProbabilityManager = Pick<ProbabilityManager, 'setProbability' | 'ensureFreshProbability'>
1016

@@ -108,6 +114,37 @@ export class BatchProcessor<C extends Configuration> implements Processor {
108114
await this.flushQueue
109115
}
110116

117+
private async runCallbacks (span: Span): Promise<boolean> {
118+
if (this.configuration.onSpanEnd) {
119+
const callbackStartTime = performance.now()
120+
let continueToBatch = true
121+
for (const callback of this.configuration.onSpanEnd) {
122+
try {
123+
let result = callback(span)
124+
125+
// @ts-expect-error result may or may not be a promise
126+
if (typeof result.then === 'function') {
127+
result = await result
128+
}
129+
130+
if (result === false) {
131+
continueToBatch = false
132+
break
133+
}
134+
} catch (err) {
135+
this.configuration.logger.error('Error in onSpanEnd callback: ' + err)
136+
}
137+
}
138+
if (continueToBatch) {
139+
const duration = millisecondsToNanoseconds(performance.now() - callbackStartTime)
140+
span.setAttribute('bugsnag.span.callbacks_duration', duration)
141+
}
142+
return continueToBatch
143+
} else {
144+
return true
145+
}
146+
}
147+
111148
private async prepareBatch (): Promise<SpanEnded[] | undefined> {
112149
if (this.spans.length === 0) {
113150
return
@@ -126,7 +163,10 @@ export class BatchProcessor<C extends Configuration> implements Processor {
126163
}
127164

128165
if (this.sampler.sample(span)) {
129-
batch.push(span)
166+
// Run any callbacks that have been registered before batching
167+
// as callbacks could cause the span to be discarded
168+
const shouldAddToBatch = await this.runCallbacks(spanEndedToSpan(span))
169+
if (shouldAddToBatch) batch.push(span)
130170
}
131171
}
132172

packages/core/lib/config.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1+
import type { OnSpanEndCallbacks } from './batch-processor'
2+
import {
3+
ATTRIBUTE_ARRAY_LENGTH_LIMIT_DEFAULT,
4+
ATTRIBUTE_COUNT_LIMIT_DEFAULT,
5+
ATTRIBUTE_STRING_VALUE_LIMIT_DEFAULT,
6+
ATTRIBUTE_ARRAY_LENGTH_LIMIT_MAX,
7+
ATTRIBUTE_COUNT_LIMIT_MAX,
8+
ATTRIBUTE_STRING_VALUE_LIMIT_MAX
9+
} from './custom-attribute-limits'
110
import type { Plugin } from './plugin'
2-
import { isLogger, isNumber, isObject, isPluginArray, isString, isStringArray, isStringWithLength } from './validation'
11+
import { isLogger, isNumber, isObject, isOnSpanEndCallbacks, isPluginArray, isString, isStringArray, isStringWithLength } from './validation'
312

413
type SetTraceCorrelation = (traceId: string, spanId: string) => void
514

@@ -40,6 +49,10 @@ export interface Configuration {
4049
plugins?: Array<Plugin<Configuration>>
4150
bugsnag?: BugsnagErrorStatic
4251
samplingProbability?: number
52+
onSpanEnd?: OnSpanEndCallbacks
53+
attributeStringValueLimit?: number
54+
attributeArrayLengthLimit?: number
55+
attributeCountLimit?: number
4356
}
4457

4558
export interface TestConfiguration {
@@ -120,6 +133,26 @@ export const schema: CoreSchema = {
120133
defaultValue: undefined,
121134
message: 'should be a number between 0 and 1',
122135
validate: (value: unknown): value is number | undefined => value === undefined || (isNumber(value) && value >= 0 && value <= 1)
136+
},
137+
onSpanEnd: {
138+
defaultValue: undefined,
139+
message: 'should be an array of functions',
140+
validate: isOnSpanEndCallbacks
141+
},
142+
attributeStringValueLimit: {
143+
defaultValue: ATTRIBUTE_STRING_VALUE_LIMIT_DEFAULT,
144+
message: `should be a number between 1 and ${ATTRIBUTE_STRING_VALUE_LIMIT_MAX}`,
145+
validate: (value: unknown): value is number => isNumber(value) && value > 0 && value <= ATTRIBUTE_STRING_VALUE_LIMIT_MAX
146+
},
147+
attributeArrayLengthLimit: {
148+
defaultValue: ATTRIBUTE_ARRAY_LENGTH_LIMIT_DEFAULT,
149+
message: `should be a number between 1 and ${ATTRIBUTE_ARRAY_LENGTH_LIMIT_MAX}`,
150+
validate: (value: unknown): value is number => isNumber(value) && value > 0 && value <= ATTRIBUTE_ARRAY_LENGTH_LIMIT_MAX
151+
},
152+
attributeCountLimit: {
153+
defaultValue: ATTRIBUTE_COUNT_LIMIT_DEFAULT,
154+
message: `should be a number between 1 and ${ATTRIBUTE_COUNT_LIMIT_MAX}`,
155+
validate: (value: unknown): value is number => isNumber(value) && value > 0 && value <= ATTRIBUTE_COUNT_LIMIT_MAX
123156
}
124157
}
125158

0 commit comments

Comments
 (0)