Skip to content

Commit

Permalink
Add sourcemap upload feature to webpack plugin (#927)
Browse files Browse the repository at this point in the history
  • Loading branch information
mvirgil authored Jan 22, 2025
1 parent bd96f5b commit 3714372
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 21 deletions.
7 changes: 6 additions & 1 deletion packages/build-plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ const { ollyWebWebpackPlugin } = require('@splunk/olly-web-build-plugins');
module.exports = {
/* ... */
plugins: [
ollyWebWebpackPlugin({ /* options */ })
ollyWebWebpackPlugin({
sourceMaps: {
realm: 'us0',
token: process.env.SPLUNK_API_TOKEN,
}
})
]
}
```
2 changes: 2 additions & 0 deletions packages/build-plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
"dist/esm/**/*.d.ts"
],
"dependencies": {
"axios": "^1.7.7",
"form-data": "^4.0.1",
"unplugin": "^1.14.1"
},
"devDependencies": {
Expand Down
48 changes: 48 additions & 0 deletions packages/build-plugins/src/httpUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
*
* Copyright 2020-2025 Splunk Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

import axios from 'axios'
import { createReadStream } from 'fs'
import * as FormData from 'form-data'

interface FileUpload {
fieldName: string
filePath: string
}

interface UploadOptions {
file: FileUpload
parameters: { [key: string]: string | number }
url: string
}

export const uploadFile = async ({ url, file, parameters }: UploadOptions): Promise<void> => {
const formData = new FormData()

formData.append(file.fieldName, createReadStream(file.filePath))

for (const [key, value] of Object.entries(parameters)) {
formData.append(key, value)
}

await axios.put(url, formData, {
headers: {
...formData.getHeaders(),
},
})
}
39 changes: 30 additions & 9 deletions packages/build-plugins/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,38 @@
* limitations under the License.
*
*/

import { createUnplugin, UnpluginFactory } from 'unplugin'

import { BannerPlugin, WebpackPluginInstance } from 'webpack'
import { computeSourceMapId, getCodeSnippet } from './utils'
import type { WebpackPluginInstance } from 'webpack'
import { computeSourceMapId, getCodeSnippet, JS_FILE_REGEX, PLUGIN_NAME } from './utils'
import { applySourceMapsUpload } from './webpack'

// eslint-disable-next-line
export interface OllyWebPluginOptions {
// define your plugin options here
/** Plugin configuration for source map ID injection and source map file uploads */
sourceMaps: {
/** Optional. If provided, this should match the "applicationName" used where SplunkRum.init() is called. */
applicationName?: string

/** Optional. If true, the plugin will inject source map IDs into the final JavaScript bundles, but it will not upload any source map files. */
disableUpload?: boolean

/** the Splunk Observability realm */
realm: string

/** API token used to authenticate the file upload requests. This is not the same as the rumAccessToken used in SplunkRum.init(). */
token: string

/** Optional. If provided, this should match the "version" used where SplunkRum.init() is called. */
version?: string
}
}

const unpluginFactory: UnpluginFactory<OllyWebPluginOptions | undefined> = () => ({
name: 'OllyWebPlugin',
const unpluginFactory: UnpluginFactory<OllyWebPluginOptions | undefined> = (options) => ({
name: PLUGIN_NAME,
webpack(compiler) {
compiler.hooks.thisCompilation.tap('OllyWebPlugin', () => {
const { webpack } = compiler
const { BannerPlugin } = webpack
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, () => {
const bannerPlugin = new BannerPlugin({
banner: ({ chunk }) => {
if (!chunk.hash) {
Expand All @@ -41,11 +58,15 @@ const unpluginFactory: UnpluginFactory<OllyWebPluginOptions | undefined> = () =>
},
entryOnly: false,
footer: true,
include: /\.(js|mjs)$/,
include: JS_FILE_REGEX,
raw: true,
})
bannerPlugin.apply(compiler)
})

if (!options.sourceMaps.disableUpload) {
applySourceMapsUpload(compiler, options)
}
},
})

Expand Down
36 changes: 27 additions & 9 deletions packages/build-plugins/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@
* limitations under the License.
*
*/

import { createHash } from 'crypto'
import { createReadStream } from 'fs'

export const PLUGIN_NAME = 'OllyWebPlugin'

export const JS_FILE_REGEX = /\.(js|cjs|mjs)$/

function shaToSourceMapId(sha: string) {
return [sha.slice(0, 8), sha.slice(8, 12), sha.slice(12, 16), sha.slice(16, 20), sha.slice(20, 32)].join('-')
}

/**
* Returns a standardized GUID value to use for a sourceMapId.
Expand All @@ -25,18 +33,28 @@ import { createHash } from 'crypto'
*/
export function computeSourceMapId(content: string): string {
const sha256 = createHash('sha256').update(content, 'utf-8').digest('hex')
const guid = [
sha256.slice(0, 8),
sha256.slice(8, 12),
sha256.slice(12, 16),
sha256.slice(16, 20),
sha256.slice(20, 32),
].join('-')
return guid
return shaToSourceMapId(sha256)
}

export async function computeSourceMapIdFromFile(sourceMapFilePath: string): Promise<string> {
const hash = createHash('sha256').setEncoding('hex')

const fileStream = createReadStream(sourceMapFilePath)
for await (const chunk of fileStream) {
hash.update(chunk)
}

const sha = hash.digest('hex')
return shaToSourceMapId(sha)
}

const SNIPPET_TEMPLATE = `;if (typeof window === 'object') { window.sourceMapIds = window.sourceMapIds || {}; let s = ''; try { throw new Error(); } catch (e) { s = (e.stack.match(/https?:\\/\\/[^\\s]+?(?::\\d+)?(?=:[\\d]+:[\\d]+)/) || [])[0]; } if (s) {window.sourceMapIds[s] = '__SOURCE_MAP_ID_PLACEHOLDER__';}};\n`

export function getCodeSnippet(sourceMapId: string): string {
return SNIPPET_TEMPLATE.replace('__SOURCE_MAP_ID_PLACEHOLDER__', sourceMapId)
}

export function getSourceMapUploadUrl(realm: string, idPathParam: string): string {
const API_BASE_URL = process.env.O11Y_API_BASE_URL || `https://api.${realm}.signalfx.com`
return `${API_BASE_URL}/v1/sourcemaps/id/${idPathParam}`
}
122 changes: 122 additions & 0 deletions packages/build-plugins/src/webpack/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
*
* Copyright 2020-2025 Splunk Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

import { computeSourceMapIdFromFile, getSourceMapUploadUrl, JS_FILE_REGEX, PLUGIN_NAME } from '../utils'
import { join } from 'path'
import { uploadFile } from '../httpUtils'
import { AxiosError } from 'axios'
import type { Compiler } from 'webpack'
import { OllyWebPluginOptions } from '../index'

/**
* The part of the webpack plugin responsible for uploading source maps from the output directory.
*/
export function applySourceMapsUpload(compiler: Compiler, options: OllyWebPluginOptions): void {
const logger = compiler.getInfrastructureLogger(PLUGIN_NAME)

compiler.hooks.afterEmit.tapAsync(PLUGIN_NAME, async (compilation, callback) => {
/*
* find JS assets' related source map assets, and collect them to an array
*/
const sourceMaps = []
compilation.assetsInfo.forEach((assetInfo, asset) => {
if (asset.match(JS_FILE_REGEX) && typeof assetInfo?.related?.sourceMap === 'string') {
sourceMaps.push(assetInfo.related.sourceMap)
}
})

if (sourceMaps.length > 0) {
logger.info(
'Uploading %d source maps to %s',
sourceMaps.length,
getSourceMapUploadUrl(options.sourceMaps.realm, '{id}'),
)
} else {
logger.warn('No source maps found.')
logger.warn('Make sure that source maps are enabled to utilize the %s plugin.', PLUGIN_NAME)
}

const uploadResults = {
success: 0,
failed: 0,
}
const parameters = Object.fromEntries(
[
['appName', options.sourceMaps.applicationName],
['appVersion', options.sourceMaps.version],
// eslint-disable-next-line @typescript-eslint/no-unused-vars
].filter(([_, value]) => typeof value !== 'undefined'),
)

/*
* resolve the source map assets to a file system path, then upload the files
*/
for (const sourceMap of sourceMaps) {
const sourceMapPath = join(compiler.outputPath, sourceMap)
const sourceMapId = await computeSourceMapIdFromFile(sourceMapPath)
const url = getSourceMapUploadUrl(options.sourceMaps.realm, sourceMapId)

logger.log('Uploading %s', sourceMap)
logger.debug('PUT', url)
try {
logger.status(new Array(uploadResults.success).fill('.').join(''))
await uploadFile({
file: {
filePath: sourceMapPath,
fieldName: 'file',
},
url,
parameters,
})
uploadResults.success += 1
} catch (e) {
uploadResults.failed += 1
const ae = e as AxiosError

const unableToUploadMessage = `Unable to upload ${sourceMapPath}`
if (ae.response && ae.response.status === 413) {
logger.warn(ae.response.status, ae.response.statusText)
logger.warn(unableToUploadMessage)
} else if (ae.response) {
logger.error(ae.response.status, ae.response.statusText)
logger.error(ae.response.data)
logger.error(unableToUploadMessage)
} else if (ae.request) {
logger.error(`Response from ${url} was not received`)
logger.error(ae.cause)
logger.error(unableToUploadMessage)
} else {
logger.error(`Request to ${url} could not be sent`)
logger.error(e)
logger.error(unableToUploadMessage)
}
}
}

if (uploadResults.success > 0) {
logger.info('Successfully uploaded %d source map(s)', uploadResults.success)
}

if (uploadResults.failed > 0) {
logger.error('Failed to upload %d source map(s)', uploadResults.failed)
}

logger.status('Uploading finished\n')
callback()
})
}
17 changes: 15 additions & 2 deletions packages/build-plugins/test/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@
* limitations under the License.
*
*/

import { expect } from 'chai'
import { computeSourceMapId, getCodeSnippet } from '../src/utils'
import { computeSourceMapId, computeSourceMapIdFromFile, getCodeSnippet, getSourceMapUploadUrl } from '../src/utils'

describe('getCodeSnippet', function () {
it('inserts the source map id into the snippet', function () {
Expand Down Expand Up @@ -47,3 +46,17 @@ describe('computeSourceMapId', function () {
expect(id).not.eq(computeSourceMapId('console.log("a different snippet gets a different id");'))
})
})

describe('computeSourceMapIdFromFilePath', function () {
it('returns an id in GUID format', async function () {
const id = await computeSourceMapIdFromFile('package.json')
expect(id).matches(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/)
})
})

describe('getSourceMapUploadUrl', function () {
it('uses the proper API based on the realm and id', function () {
const url = getSourceMapUploadUrl('us0', 'd77ec5d8-4fb5-fbc8-1897-54b54e939bcd')
expect(url).eq('https://api.us0.signalfx.com/v1/sourcemaps/id/d77ec5d8-4fb5-fbc8-1897-54b54e939bcd')
})
})

0 comments on commit 3714372

Please sign in to comment.