├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .mocharc.json ├── .npmrc ├── .parcelrc ├── .prettierrc.mjs ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── PRIVACY.md ├── README.md ├── assets ├── chrome.svg ├── edge.svg ├── firefox.svg ├── icon.png ├── logo.svg ├── popup-preview.mp4 ├── safari.svg └── store │ ├── overview.md │ ├── popup-configuration-logging-screenshot.png │ ├── popup-configuration-safari.png │ ├── popup-configuration-screenshot.png │ ├── popup-configuration-tracing-screenshot.png │ ├── popup-cropped.png │ └── popup.png ├── config ├── dashboard.yaml ├── dashboards │ └── demo-dashboard.json ├── grafana-datasources.yaml ├── otel-collector.yaml ├── prometheus.yaml └── readme.html ├── docker-compose.yaml ├── experimental └── otel-cli-preexec-test.sh ├── generated └── safari-xcode │ └── Browser Ext for OpenTelemetry │ ├── Browser Ext for OpenTelemetry Extension │ ├── Browser_Ext_for_OpenTelemetry_Extension.entitlements │ ├── Info.plist │ └── SafariWebExtensionHandler.swift │ ├── Browser Ext for OpenTelemetry.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ ├── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcuserdata │ │ │ └── theodorebrockman.xcuserdatad │ │ │ └── UserInterfaceState.xcuserstate │ ├── xcshareddata │ │ └── xcschemes │ │ │ └── Browser Ext for OpenTelemetry.xcscheme │ └── xcuserdata │ │ └── theodorebrockman.xcuserdatad │ │ └── xcschemes │ │ └── xcschememanagement.plist │ └── Browser Ext for OpenTelemetry │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── mac-icon-128@1x.png │ │ ├── mac-icon-128@2x.png │ │ ├── mac-icon-16@1x.png │ │ ├── mac-icon-16@2x.png │ │ ├── mac-icon-256@1x.png │ │ ├── mac-icon-256@2x.png │ │ ├── mac-icon-32@1x.png │ │ ├── mac-icon-32@2x.png │ │ ├── mac-icon-512@1x.png │ │ └── mac-icon-512@2x.png │ ├── Contents.json │ └── LargeIcon.imageset │ │ └── Contents.json │ ├── Base.lproj │ ├── Main.html │ └── Main.storyboard │ ├── Browser Ext for OpenTelemetry.entitlements │ ├── Browser_Ext_for_OpenTelemetry.entitlements │ ├── Info.plist │ ├── Resources │ ├── Icon.png │ ├── Script.js │ └── Style.css │ └── ViewController.swift ├── inlinefunc.config.mjs ├── package.json ├── patches ├── @opentelemetry__instrumentation-fetch@0.49.1.patch ├── @opentelemetry__instrumentation-user-interaction@0.35.0.patch ├── @opentelemetry__instrumentation-xml-http-request@0.49.1.patch ├── @opentelemetry__otlp-proto-exporter-base@0.49.1.patch └── @protobufjs__inquire@1.1.0.patch ├── pnpm-lock.yaml ├── postcss.config.cjs ├── src ├── background.ts ├── components │ ├── ColorModeSwitch.tsx │ ├── Configuration.css │ ├── Configuration.tsx │ ├── Editor │ │ ├── hover.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── theme.ts │ ├── GeneralConfiguration.tsx │ ├── KeyShortcut.tsx │ ├── KeyValueInput │ │ ├── KeyValueRow.tsx │ │ └── index.tsx │ ├── LinkSection.css │ ├── LinkSection.tsx │ ├── LogConfiguration.tsx │ ├── TagsInput.tsx │ └── TraceConfiguration.tsx ├── config.ts ├── content-script.ts ├── generated │ └── schemas │ │ └── configuration.schema.json ├── hooks │ ├── platform.ts │ └── storage.ts ├── listeners │ ├── index.ts │ ├── permissions │ │ └── index.ts │ ├── runtime │ │ ├── index.ts │ │ └── onConnect │ │ │ └── index.ts │ ├── storage │ │ ├── index.ts │ │ └── onChanged │ │ │ ├── configTextSync.ts │ │ │ ├── forwardChanges.ts │ │ │ ├── index.ts │ │ │ └── matchPatternsSync.ts │ └── tabs │ │ ├── index.ts │ │ └── onUpdated │ │ └── index.ts ├── message-relay.ts ├── popup.css ├── popup.tsx ├── scripts │ ├── generate-jsonschema │ │ └── main.ts │ └── injectpackage-env │ │ └── main.ts ├── storage │ └── local │ │ ├── configuration │ │ ├── backend.ts │ │ ├── configuration.test.ts │ │ ├── configuration.ts │ │ ├── content-script.ts │ │ └── index.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ └── internal.ts ├── tabs │ ├── preview.css │ └── preview.tsx ├── telemetry │ └── logs.ts ├── types.ts └── utils │ ├── background-ports.ts │ ├── constants.ts │ ├── generics.ts │ ├── logging.ts │ ├── match-pattern.test.ts │ ├── match-pattern.ts │ ├── options.ts │ ├── platform.ts │ └── serde.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: [tbrockman] 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | ignore: 6 | - dependency-name: "@opentelemetry/*" # ignore otel dependencies until patches aren't needed 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | submodules: recursive 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: '20' 16 | - uses: pnpm/action-setup@v4 17 | name: Install pnpm 18 | with: 19 | run_install: false 20 | - name: Get pnpm store directory 21 | shell: bash 22 | run: | 23 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 24 | - uses: actions/cache@v4 25 | name: Setup pnpm cache 26 | with: 27 | path: ${{ env.STORE_PATH }} 28 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 29 | restore-keys: | 30 | ${{ runner.os }}-pnpm-store- 31 | - run: pnpm install 32 | - run: pnpm build 33 | - uses: actions/upload-artifact@v4 34 | with: 35 | path: build 36 | name: build -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | inputs: 4 | version: 5 | description: 'Version to publish' 6 | required: true 7 | verbose: 8 | description: 'Verbose mode' 9 | required: false 10 | default: 'false' 11 | 12 | jobs: 13 | run-bpp: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Download artifact 17 | uses: dawidd6/action-download-artifact@v3 18 | with: 19 | workflow: build.yml 20 | workflow_conclusion: success 21 | branch: main 22 | event: push 23 | name: build 24 | path: build 25 | - name: Browser Platform Publish 26 | uses: PlasmoHQ/bpp@v3 27 | with: 28 | keys: ${{ secrets.SUBMIT_KEYS }} 29 | chrome-file: 'build/chrome-mv3-prod.zip' 30 | firefox-file: 'build/firefox-mv3-prod.zip' 31 | edge-file: 'build/edge-mv3-prod.zip' 32 | edge-notes: "Publishing latest extension version (${{ inputs.version }}) to the Edge Store." 33 | # opera-file: 'build/opera-mv3-prod.zip' 34 | verbose: ${{ inputs.verbose }} -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | on: 3 | pull_request: 4 | branches: [main] 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | submodules: recursive 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: '20' 16 | - uses: pnpm/action-setup@v4 17 | name: Install pnpm 18 | with: 19 | run_install: false 20 | - name: Get pnpm store directory 21 | shell: bash 22 | run: | 23 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 24 | - uses: actions/cache@v4 25 | name: Setup pnpm cache 26 | with: 27 | path: ${{ env.STORE_PATH }} 28 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 29 | restore-keys: | 30 | ${{ runner.os }}-pnpm-store- 31 | - run: pnpm install 32 | - run: pnpm test:unit 33 | - run: pnpm build 34 | auto-approve: 35 | runs-on: ubuntu-latest 36 | needs: test 37 | permissions: 38 | contents: write 39 | pull-requests: write 40 | if: ${{ github.actor == 'dependabot[bot]' }} 41 | steps: 42 | - name: Dependabot metadata 43 | id: metadata 44 | uses: dependabot/fetch-metadata@v1 45 | with: 46 | github-token: '${{ secrets.GITHUB_TOKEN }}' 47 | - name: Enable auto-merge for Dependabot PRs 48 | run: | 49 | gh pr merge --auto --squash "$PR_URL" 50 | gh pr review --approve "$PR_URL" 51 | env: 52 | PR_URL: ${{github.event.pull_request.html_url}} 53 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # misc 13 | .DS_Store 14 | *.pem 15 | 16 | # debug 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | .pnpm-debug.log* 21 | 22 | # local env files 23 | .env*.local 24 | 25 | out/ 26 | /build 27 | dist/ 28 | 29 | # plasmo 30 | .plasmo 31 | 32 | # typescript 33 | .tsbuildinfo 34 | 35 | # docker compose volumes 36 | qwdata 37 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/mocharc.json", 3 | "require": "tsx" 4 | } -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | link-workspace-packages = deep 2 | disallow-workspace-cycles = true -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@plasmohq/parcel-config", 3 | "resolvers": [ 4 | "parcel-resolver-inlinefunc", 5 | "..." 6 | ] 7 | } -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | export default { 5 | printWidth: 80, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: false, 9 | singleQuote: false, 10 | trailingComma: "none", 11 | bracketSpacing: true, 12 | bracketSameLine: true, 13 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 14 | importOrder: [ 15 | "", // Node.js built-in modules 16 | "", // Imports not matched by other special words or groups. 17 | "", // Empty line 18 | "^@plasmo/(.*)$", 19 | "", 20 | "^@plasmohq/(.*)$", 21 | "", 22 | "^~(.*)$", 23 | "", 24 | "^[./]" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cssVariables.lookupFiles": [ 3 | "**/*.css", 4 | "**/*.scss", 5 | "**/*.sass", 6 | "**/*.less", 7 | "node_modules/@mantine/core/styles.css" 8 | ] 9 | } -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | This extension does not store or export any data other than where and what you expressly choose. 2 | 3 | It does not collect or exfiltrate any data on its own, but it's understandable that you may want to verify this for yourself. You're encouraged to review the source code and build the extension yourself if you have any concerns. 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](assets/logo.svg) 2 | 3 |
4 |

Available on:

5 |

6 | chrome download 7 | | 8 | safari download 9 | | 10 | edge download 11 | | 12 | firefox nightly download 13 | | 14 | ...or build it yourself! 15 |

16 |

made with 🧪 by theo.

17 |

18 |
19 | 20 | ## Features 21 | 22 | - Instruments selected webpages to **generate logs and traces**, sent to an OTLP-compatible of _your_ choosing 23 | - Choose from the available automatic instrumentations (or [contribute your own](contributing.md)) 24 |

25 | 26 |

27 | - Choose where and how you want it to run! Don't worry about the extension tracking every single webpage, use [match patterns](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns) (ex. `https://.com/*`) to specify the pages it should run on and have access to. 28 |

29 | 30 |

31 | - No content-security policy errors! Works around typical CSP limitations by making `fetch` requests from the background script instead of the webpage 32 | - Propagate b3 and w3c trace context to websites of your choosing (matched by regular expressions) 33 | 34 | ## Browser compatibility 35 | 36 | This extension is compatible with [all major browsers](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/executeScript#browser_compatibility) as of Firefox 128! 🎉 37 | 38 | 39 | 40 | 41 | ## Security considerations 42 | 43 | > [!WARNING] 44 | > You probably shouldn't run this extension on webpages you don't trust 45 | 46 | ### Why? 47 | 48 | The extension background script exports any Protobuf-encoded OTLP data that it receives from the injected content script that it's able to parse. 49 | 50 | While some mitigations are implemented, the data can always be tampered with by any malicious Javascript running in the same context as the content script, and as such the integrity of the data cannot be guaranteed. This may result in minor frustrations like storing a bunch of garbage or worse depending on how your backend decodes Protobuf data. 51 | 52 | So, just as a general safety measure, it's probably best if you don't allow the extension to run in untrusted pages (and you should kinda generally avoid running code in untrusted webpages if you aren't already, anyhow). 53 | 54 | ### Can it be fixed? 55 | 56 | Probably not in the near future. Unless browsers expose telemetry themselves, there's no way for the instrumentation to both run in an isolated context as well as gather the desired data. 57 | 58 | ## Developing 59 | 60 | Clone the repository: 61 | 62 | ```bash 63 | git clone https://github.com/tbrockman/browser-extension-for-opentelemetry 64 | ``` 65 | 66 | Install dependencies: 67 | 68 | ```bash 69 | pnpm install 70 | ``` 71 | 72 | Start the OpenTelemetry stack (Grafana + Quickwit + `opentelemetry--contrib`): (optional if you have your own) 73 | 74 | ```bash 75 | docker compose up -d 76 | ``` 77 | 78 | Run the development server: 79 | 80 | ```bash 81 | pnpm dev 82 | # or for targetting a browser other than Chrome (the default) 83 | pnpm dev --target=edge-mv3 84 | ``` 85 | 86 | Then, open your browser and load the appropriate development build. For example, if you're developing for Chrome, using manifest v3, use: `build/chrome-mv3-dev`. 87 | 88 | ## Making a production build 89 | 90 | Run the following: 91 | 92 | ```bash 93 | pnpm build 94 | ``` 95 | 96 | or, for targeting a specific browser: 97 | 98 | ```bash 99 | pnpm build:chrome 100 | # or 101 | pnpm build:safari 102 | # or 103 | pnpm build:edge 104 | # or 105 | pnpm build:firefox 106 | ``` 107 | 108 | Then, follow the same steps as with running the development server, but load the appropriate production build from the `build` directory, i.e: `build/chrome-mv3-prod`. 109 | 110 | ### Safari 111 | 112 | Safari requires a bit of extra work. After building the extension, run the following commands to convert the extension to a an XCode project: 113 | 114 | ```bash 115 | pnpm convert:safari 116 | ``` 117 | 118 | Then, build the extension in XCode (using the MacOS target), and enable it in Safari. 119 | 120 | > [!NOTE] 121 | > Safari requires extensions to be signed before they can be installed. You can either sign the extension yourself, or load it as an unsigned extension after enabling "allow unsigned extensions" in Safari's developer settings. 122 | -------------------------------------------------------------------------------- /assets/chrome.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/edge.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/assets/icon.png -------------------------------------------------------------------------------- /assets/popup-preview.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/assets/popup-preview.mp4 -------------------------------------------------------------------------------- /assets/store/overview.md: -------------------------------------------------------------------------------- 1 | An extension for instrumenting webpages using OpenTelemetry 2 | 3 | Generate traces and capture logs to piece together your frontend and backend problems, all without adding any dependencies and making your users download more Javascript. 4 | 5 | Free and open-source: https://github.com/tbrockman/browser-extension-for-opentelemetry 6 | 7 | In early development, if you notice anything is broken please make an issue! -------------------------------------------------------------------------------- /assets/store/popup-configuration-logging-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/assets/store/popup-configuration-logging-screenshot.png -------------------------------------------------------------------------------- /assets/store/popup-configuration-safari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/assets/store/popup-configuration-safari.png -------------------------------------------------------------------------------- /assets/store/popup-configuration-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/assets/store/popup-configuration-screenshot.png -------------------------------------------------------------------------------- /assets/store/popup-configuration-tracing-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/assets/store/popup-configuration-tracing-screenshot.png -------------------------------------------------------------------------------- /assets/store/popup-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/assets/store/popup-cropped.png -------------------------------------------------------------------------------- /assets/store/popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/assets/store/popup.png -------------------------------------------------------------------------------- /config/dashboard.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: "OpenTelemetry Browser Extension dashboard provider" 5 | orgId: 1 6 | type: file 7 | disableDeletion: false 8 | updateIntervalSeconds: 10 9 | allowUiUpdates: false 10 | options: 11 | path: /var/lib/grafana/dashboards 12 | foldersFromFilesStructure: true 13 | -------------------------------------------------------------------------------- /config/grafana-datasources.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | uid: prometheus 7 | access: proxy 8 | orgId: 1 9 | url: http://prometheus:9090 10 | basicAuth: false 11 | isDefault: false 12 | version: 1 13 | editable: false 14 | jsonData: 15 | httpMethod: GET 16 | - name: Quickwit Logs 17 | uid: quickwit-logs 18 | type: quickwit-quickwit-datasource 19 | url: http://quickwit:7280/api/v1 20 | editable: false 21 | jsonData: 22 | index: 'otel-logs-v0_7' 23 | logMessageField: body.message 24 | logLevelField: severity_text 25 | - name: Quickwit Traces 26 | uid: quickwit-traces 27 | type: quickwit-quickwit-datasource 28 | url: http://quickwit:7280/api/v1 29 | editable: false 30 | isDefault: true 31 | jsonData: 32 | index: 'otel-traces-v0_7' 33 | -------------------------------------------------------------------------------- /config/otel-collector.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | http: 5 | endpoint: 0.0.0.0:4318 6 | cors: 7 | allowed_origins: 8 | - "*" 9 | allowed_headers: 10 | - "*" 11 | grpc: 12 | endpoint: 0.0.0.0:4317 13 | exporters: 14 | otlp/quickwit: 15 | endpoint: http://quickwit:7281 16 | tls: 17 | insecure: true 18 | debug: 19 | verbosity: detailed 20 | service: 21 | telemetry: 22 | logs: 23 | level: "debug" 24 | pipelines: 25 | traces: 26 | receivers: [otlp] 27 | exporters: [otlp/quickwit, debug] 28 | logs: 29 | receivers: [otlp] 30 | exporters: [otlp/quickwit, debug] -------------------------------------------------------------------------------- /config/prometheus.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | evaluation_interval: 15s 4 | 5 | scrape_configs: 6 | - job_name: 'prometheus' 7 | static_configs: 8 | - targets: [ 'localhost:9090' ] 9 | -------------------------------------------------------------------------------- /config/readme.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

👋 Welcome! / 📚 README

4 |

5 | Thanks for taking the time to check out the project. If you feel the urge, please consider contributing to the 6 | project or sponsoring the developer. 7 |

8 |

9 | 10 |

Don't see any data here?

11 | Check out the 12 | FAQ. 13 |

14 |

Demo

15 |

This dashboard is meant to serve as a basic demonstration of the kind of queries and visualizations you can make 16 | with some of the data you collect with the extension.

17 |

18 | It's currently under development, so there might be a bug or missing feature or two. 19 |

20 |

How to query the data

21 |

The data in this dashboard primarly leverages Quickwit. Quickwit supports a 22 | Lucene-like query 23 | language (and Elasticsearch-search compatible API) which 24 | you can learn 25 | more about here.

26 | 27 |

At the top of the dashboard you can also filter by log and trace attributes, which will automatically apply to 28 | the 29 | related dashboard visualizations.

30 |
-------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | quickwit: 3 | image: quickwit/quickwit 4 | volumes: 5 | - ./qwdata:/quickwit/qwdata 6 | environment: 7 | QW_ENABLE_OTLP_ENDPOINT: "true" 8 | ports: 9 | - 7280:7280 10 | - 7281:7281 11 | command: [ "run" ] 12 | attach: false # comment this line if you'd like to view quickwit log output 13 | 14 | otel-collector: 15 | image: otel/opentelemetry-collector:latest 16 | command: [ "--config=/etc/otel-collector.yaml" ] 17 | volumes: 18 | - ./config/otel-collector.yaml:/etc/otel-collector.yaml 19 | ports: 20 | - "4318:4318" # otlp http 21 | - "4317:4317" # otlp grpc 22 | restart: always 23 | depends_on: 24 | - quickwit 25 | - prometheus 26 | grafana: 27 | image: grafana/grafana-oss 28 | volumes: 29 | - ./config/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml 30 | - ./config/dashboard.yaml:/etc/grafana/provisioning/dashboards/main.yaml 31 | - ./config/dashboards:/var/lib/grafana/dashboards 32 | environment: 33 | - GF_INSTALL_PLUGINS=https://github.com/quickwit-oss/quickwit-datasource/releases/download/v0.4.1/quickwit-quickwit-datasource-0.4.1.zip;quickwit-quickwit-datasource 34 | - GF_AUTH_ANONYMOUS_ENABLED=true 35 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin 36 | - GF_AUTH_DISABLE_LOGIN_FORM=true 37 | - GF_FEATURE_TOGGLES_ENABLE=metricsSummary 38 | ports: 39 | - 3000:3000 # ui 40 | # - "${MAP_HOST_GRAFANA:-127.0.0.1}:3000:3000" 41 | attach: false 42 | 43 | prometheus: 44 | image: prom/prometheus:latest 45 | command: 46 | - --config.file=/etc/prometheus.yaml 47 | - --web.enable-remote-write-receiver 48 | - --enable-feature=exemplar-storage 49 | volumes: 50 | - ./config/prometheus.yaml:/etc/prometheus.yaml 51 | ports: 52 | - "9090" 53 | -------------------------------------------------------------------------------- /experimental/otel-cli-preexec-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DEFAULT_BIN_PATH_REGEX="cat" 4 | DEFAULT_SERVICE="otel-cli" 5 | 6 | # Cache the output of `which` command to avoid repeated calls 7 | declare -A path_cache 8 | 9 | function Initialize() { 10 | EnsureDependencies || return 1 11 | shopt -s extdebug 12 | Trap 13 | } 14 | 15 | function Trap() { 16 | trap 'ProxyOtel "$BASH_COMMAND" "$@"' DEBUG 17 | } 18 | 19 | function Untrap() { 20 | trap - DEBUG 21 | } 22 | 23 | function EnsureDependencies() { 24 | local dependencies=("otel-cli" "which" "trap" "shopt") 25 | for dependency in "${dependencies[@]}"; do 26 | if ! command -v "$dependency" &> /dev/null; then 27 | echo "$dependency could not be found. Please ensure $dependency is installed and available in the PATH." 28 | return 1 29 | fi 30 | done 31 | } 32 | 33 | function GetPath() { 34 | local program="$1" 35 | # Check if path is already cached 36 | if [[ -n "${path_cache[$program]}" ]]; then 37 | echo "${path_cache[$program]}" 38 | else 39 | local path 40 | path=$(which "$program") 41 | path_cache["$program"]=$path 42 | echo "$path" 43 | fi 44 | } 45 | 46 | function ProxyOtel() { 47 | [ -n "$COMP_LINE" ] && return # do nothing if shell completion 48 | [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ] && return # do nothing for prompts 49 | 50 | Untrap # disable the DEBUG trap to avoid infinite loop 51 | 52 | local program="${1%% *}" # Extracting the command name 53 | local path 54 | path=$(GetPath "$program") 55 | echo "in proxy otel function: ${1}, program: ${program}, path: ${path}" 56 | local exit_code 57 | exit_code=1 58 | 59 | local BIN_PATH_REGEX 60 | BIN_PATH_REGEX=${OTEL_CLI_REGEX:-$DEFAULT_BIN_PATH_REGEX} 61 | 62 | local result 63 | 64 | if [[ "$path" =~ $BIN_PATH_REGEX ]]; then 65 | echo "Running ${1} with otel-cli exec" 66 | $(otel-cli exec -- ${1}) || true 67 | exit_code=1 # don't run the original command 68 | else 69 | exit_code=0 # run the original command we trapped as it didn't match the regex 70 | fi 71 | Trap 72 | return ${exit_code} 73 | } 74 | 75 | Initialize -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry Extension/Browser_Ext_for_OpenTelemetry_Extension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry Extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.Safari.web-extension 9 | NSExtensionPrincipalClass 10 | $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry Extension/SafariWebExtensionHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariWebExtensionHandler.swift 3 | // Browser Ext for OpenTelemetry Extension 4 | // 5 | // Created by theodore brockman on 7/22/24. 6 | // 7 | 8 | import SafariServices 9 | import os.log 10 | 11 | class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { 12 | 13 | func beginRequest(with context: NSExtensionContext) { 14 | let request = context.inputItems.first as? NSExtensionItem 15 | 16 | let profile: UUID? 17 | if #available(iOS 17.0, macOS 14.0, *) { 18 | profile = request?.userInfo?[SFExtensionProfileKey] as? UUID 19 | } else { 20 | profile = request?.userInfo?["profile"] as? UUID 21 | } 22 | 23 | let message: Any? 24 | if #available(iOS 15.0, macOS 11.0, *) { 25 | message = request?.userInfo?[SFExtensionMessageKey] 26 | } else { 27 | message = request?.userInfo?["message"] 28 | } 29 | 30 | os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@ (profile: %@)", String(describing: message), profile?.uuidString ?? "none") 31 | 32 | let response = NSExtensionItem() 33 | if #available(iOS 15.0, macOS 11.0, *) { 34 | response.userInfo = [ SFExtensionMessageKey: [ "echo": message ] ] 35 | } else { 36 | response.userInfo = [ "message": [ "echo": message ] ] 37 | } 38 | 39 | context.completeRequest(returningItems: [ response ], completionHandler: nil) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry.xcodeproj/project.xcworkspace/xcuserdata/theodorebrockman.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry.xcodeproj/project.xcworkspace/xcuserdata/theodorebrockman.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry.xcodeproj/xcshareddata/xcschemes/Browser Ext for OpenTelemetry.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry.xcodeproj/xcuserdata/theodorebrockman.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Browser Ext for OpenTelemetry.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 663F07162C4F53460061CB23 16 | 17 | primary 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Browser Ext for OpenTelemetry 4 | // 5 | // Created by theodore brockman on 7/22/24. 6 | // 7 | 8 | import Cocoa 9 | 10 | @main 11 | class AppDelegate: NSObject, NSApplicationDelegate { 12 | 13 | func applicationDidFinishLaunching(_ notification: Notification) { 14 | // Override point for customization after application launch. 15 | } 16 | 17 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 18 | return true 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "mac-icon-16@1x.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "mac-icon-16@2x.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "mac-icon-32@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "mac-icon-32@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "mac-icon-128@1x.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "mac-icon-128@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "mac-icon-256@1x.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "mac-icon-256@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "mac-icon-512@1x.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "mac-icon-512@2x.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Assets.xcassets/LargeIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "universal", 13 | "scale" : "3x" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Base.lproj/Main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Browser Ext for OpenTelemetry Icon 14 |

You can turn on Browser Ext for OpenTelemetry’s extension in Safari Extensions preferences.

15 |

Browser Ext for OpenTelemetry’s extension is currently on. You can turn it off in Safari Extensions preferences.

16 |

Browser Ext for OpenTelemetry’s extension is currently off. You can turn it on in Safari Extensions preferences.

17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Browser_Ext_for_OpenTelemetry.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SFSafariWebExtensionConverterVersion 6 | 15.4 7 | 8 | 9 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Resources/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbrockman/browser-extension-for-opentelemetry/14d3a7db48ee1e248827e6f9d903fb012dc2028b/generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Resources/Icon.png -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Resources/Script.js: -------------------------------------------------------------------------------- 1 | function show(enabled, useSettingsInsteadOfPreferences) { 2 | if (useSettingsInsteadOfPreferences) { 3 | document.getElementsByClassName('state-on')[0].innerText = "Browser Ext for OpenTelemetry’s extension is currently on. You can turn it off in the Extensions section of Safari Settings."; 4 | document.getElementsByClassName('state-off')[0].innerText = "Browser Ext for OpenTelemetry’s extension is currently off. You can turn it on in the Extensions section of Safari Settings."; 5 | document.getElementsByClassName('state-unknown')[0].innerText = "You can turn on Browser Ext for OpenTelemetry’s extension in the Extensions section of Safari Settings."; 6 | document.getElementsByClassName('open-preferences')[0].innerText = "Quit and Open Safari Settings…"; 7 | } 8 | 9 | if (typeof enabled === "boolean") { 10 | document.body.classList.toggle(`state-on`, enabled); 11 | document.body.classList.toggle(`state-off`, !enabled); 12 | } else { 13 | document.body.classList.remove(`state-on`); 14 | document.body.classList.remove(`state-off`); 15 | } 16 | } 17 | 18 | function openPreferences() { 19 | webkit.messageHandlers.controller.postMessage("open-preferences"); 20 | } 21 | 22 | document.querySelector("button.open-preferences").addEventListener("click", openPreferences); 23 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/Resources/Style.css: -------------------------------------------------------------------------------- 1 | * { 2 | -webkit-user-select: none; 3 | -webkit-user-drag: none; 4 | cursor: default; 5 | } 6 | 7 | :root { 8 | color-scheme: light dark; 9 | 10 | --spacing: 20px; 11 | } 12 | 13 | html { 14 | height: 100%; 15 | } 16 | 17 | body { 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | flex-direction: column; 22 | 23 | gap: var(--spacing); 24 | margin: 0 calc(var(--spacing) * 2); 25 | height: 100%; 26 | 27 | font: -apple-system-short-body; 28 | text-align: center; 29 | } 30 | 31 | body:not(.state-on, .state-off) :is(.state-on, .state-off) { 32 | display: none; 33 | } 34 | 35 | body.state-on :is(.state-off, .state-unknown) { 36 | display: none; 37 | } 38 | 39 | body.state-off :is(.state-on, .state-unknown) { 40 | display: none; 41 | } 42 | 43 | button { 44 | font-size: 1em; 45 | } 46 | -------------------------------------------------------------------------------- /generated/safari-xcode/Browser Ext for OpenTelemetry/Browser Ext for OpenTelemetry/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Browser Ext for OpenTelemetry 4 | // 5 | // Created by theodore brockman on 7/22/24. 6 | // 7 | 8 | import Cocoa 9 | import SafariServices 10 | import WebKit 11 | 12 | let extensionBundleIdentifier = "com.theo.Browser-Ext-for-OpenTelemetry.Extension" 13 | 14 | class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHandler { 15 | 16 | @IBOutlet var webView: WKWebView! 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | 21 | self.webView.navigationDelegate = self 22 | 23 | self.webView.configuration.userContentController.add(self, name: "controller") 24 | 25 | self.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!) 26 | } 27 | 28 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 29 | SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in 30 | guard let state = state, error == nil else { 31 | // Insert code to inform the user that something went wrong. 32 | return 33 | } 34 | 35 | DispatchQueue.main.async { 36 | if #available(macOS 13, *) { 37 | webView.evaluateJavaScript("show(\(state.isEnabled), true)") 38 | } else { 39 | webView.evaluateJavaScript("show(\(state.isEnabled), false)") 40 | } 41 | } 42 | } 43 | } 44 | 45 | func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 46 | if (message.body as! String != "open-preferences") { 47 | return; 48 | } 49 | 50 | SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in 51 | DispatchQueue.main.async { 52 | NSApplication.shared.terminate(nil) 53 | } 54 | } 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /inlinefunc.config.mjs: -------------------------------------------------------------------------------- 1 | import { polyfillNode } from "esbuild-plugin-polyfill-node"; 2 | 3 | export default { 4 | plugins: [ 5 | polyfillNode({ 6 | polyfills: { 7 | path: true, 8 | process: false, 9 | } 10 | }) 11 | ], 12 | define: { 13 | global: 'undefined', 14 | 'process.env.NODE_ENV': `"${process.env.NODE_ENV}"`, 15 | 'process.env.PLASMO_BROWSER': `"${process.env.PLASMO_BROWSER}"`, 16 | 'process.env.PLASMO_PUBLIC_PACKAGE_VERSION': `"${process.env.PLASMO_PUBLIC_PACKAGE_VERSION}"`, 17 | 'process.env.PLASMO_PUBLIC_PACKAGE_NAME': `"${process.env.PLASMO_PUBLIC_PACKAGE_NAME}"`, 18 | } 19 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-extension-for-opentelemetry", 3 | "displayName": "Browser Extension for OpenTelemetry", 4 | "version": "0.0.20", 5 | "description": "A browser extension for automatically instrumenting webpages using OpenTelemetry", 6 | "author": "Theodore Brockman ", 7 | "packageManager": "pnpm@9.10.0", 8 | "scripts": { 9 | "build": "npm-run-all build:safari build:chrome build:edge build:opera build:firefox", 10 | "build:chrome": "tsx src/scripts/injectpackage-env/main.ts plasmo build --target=chrome-mv3 --zip --hoist", 11 | "build:firefox": "tsx src/scripts/injectpackage-env/main.ts plasmo build --target=firefox-mv3 --zip --hoist", 12 | "build:edge": "tsx src/scripts/injectpackage-env/main.ts plasmo build --target=edge-mv3 --zip --hoist", 13 | "build:safari": "tsx src/scripts/injectpackage-env/main.ts plasmo build --target=safari-mv3 --zip --hoist", 14 | "convert:safari": "cd build && xcrun safari-web-extension-converter safari-mv3-prod --project-location ../generated/safari-xcode/ --app-name 'Browser Ext for OpenTelemetry' --bundle-identifier 'com.theo.Browser-Extension-for-OpenTelemetry' --macos-only", 15 | "build:opera": "tsx src/scripts/injectpackage-env/main.ts plasmo build --target=opera-mv3 --zip --hoist", 16 | "dev": "tsx src/scripts/injectpackage-env/main.ts plasmo dev", 17 | "clean": "rm -fr build", 18 | "test": "pnpm test:unit", 19 | "test:unit": "mocha --require mocha-suppress-logs 'src/**/*.test.ts'", 20 | "generate:jsonschema": "tsx src/scripts/generate-jsonschema/main.ts" 21 | }, 22 | "dependencies": { 23 | "@codemirror/autocomplete": "^6.18.4", 24 | "@codemirror/commands": "^6.8.0", 25 | "@codemirror/lang-json": "^6.0.1", 26 | "@codemirror/language": "^6.10.8", 27 | "@codemirror/lint": "^6.8.4", 28 | "@codemirror/state": "^6.5.2", 29 | "@codemirror/view": "^6.36.2", 30 | "@lezer/common": "^1.2.3", 31 | "@lezer/highlight": "^1.2.1", 32 | "@lezer/json": "^1.0.3", 33 | "@mantine/core": "^7.6.1", 34 | "@mantine/hooks": "^7.6.1", 35 | "@opentelemetry/api-logs": "^0.49.1", 36 | "@opentelemetry/auto-instrumentations-web": "^0.36.0", 37 | "@opentelemetry/context-zone": "^1.22.0", 38 | "@opentelemetry/core": "^1.22.0", 39 | "@opentelemetry/exporter-logs-otlp-proto": "^0.49.1", 40 | "@opentelemetry/exporter-trace-otlp-proto": "^0.49.1", 41 | "@opentelemetry/instrumentation": "^0.49.1", 42 | "@opentelemetry/instrumentation-document-load": "^0.35.0", 43 | "@opentelemetry/instrumentation-fetch": "^0.49.1", 44 | "@opentelemetry/instrumentation-user-interaction": "^0.35.0", 45 | "@opentelemetry/instrumentation-xml-http-request": "^0.49.1", 46 | "@opentelemetry/otlp-exporter-base": "^0.49.1", 47 | "@opentelemetry/otlp-proto-exporter-base": "^0.49.1", 48 | "@opentelemetry/propagator-b3": "^1.22.0", 49 | "@opentelemetry/resources": "^1.22.0", 50 | "@opentelemetry/sdk-logs": "^0.49.1", 51 | "@opentelemetry/sdk-trace-base": "^1.22.0", 52 | "@opentelemetry/sdk-trace-web": "^1.22.0", 53 | "@opentelemetry/semantic-conventions": "^1.22.0", 54 | "@tabler/icons-react": "^3.29.0", 55 | "@uiw/codemirror-theme-vscode": "^4.23.7", 56 | "@uiw/codemirror-themes": "^4.23.7", 57 | "@uiw/react-codemirror": "^4.23.7", 58 | "browser-extension-url-match": "^1.2.0", 59 | "codemirror-json-schema": "https://github.com/tbrockman/codemirror-json-schema/releases/download/otel-v1.0.1/codemirror-json-schema-0.7.11.tgz", 60 | "deepmerge-ts": "^7.1.4", 61 | "markdown-it": "^14.1.0", 62 | "react": "18.2.0", 63 | "react-dom": "18.2.0", 64 | "react-error-boundary": "^5.0.0", 65 | "ts-mixer": "^6.0.4", 66 | "uuidv7": "^1.0.2" 67 | }, 68 | "devDependencies": { 69 | "@ianvs/prettier-plugin-sort-imports": "4.4.1", 70 | "@parcel/config-default": "^2.13.3", 71 | "@parcel/packager-ts": "2.13.3", 72 | "@plasmohq/parcel-config": "^0.41.1", 73 | "@types/chrome": "0.0.301", 74 | "@types/jest": "^29.5.14", 75 | "@types/mocha": "^10.0.10", 76 | "@types/node": "22.13.0", 77 | "@types/react": "18.2.61", 78 | "@types/react-dom": "18.2.19", 79 | "chai": "^5.1.2", 80 | "cross-env": "^7.0.3", 81 | "esbuild-plugin-polyfill-node": "^0.3.0", 82 | "mocha": "^11.1.0", 83 | "mocha-suppress-logs": "^0.5.1", 84 | "npm-run-all": "^4.1.5", 85 | "parcel-resolver-inlinefunc": "^1.0.0", 86 | "plasmo": "^0.89.4", 87 | "postcss": "^8.5.1", 88 | "postcss-preset-mantine": "^1.13.0", 89 | "postcss-simple-vars": "^7.0.1", 90 | "prettier": "3.4.2", 91 | "process": "^0.11.10", 92 | "ts-json-schema-generator": "^2.3.0", 93 | "tsx": "^4.19.2", 94 | "typescript": "5.7.3" 95 | }, 96 | "manifest": { 97 | "host_permissions": [ 98 | "http://localhost/*" 99 | ], 100 | "optional_host_permissions": [ 101 | "" 102 | ], 103 | "permissions": [ 104 | "scripting", 105 | "storage" 106 | ], 107 | "browser_specific_settings": { 108 | "gecko": { 109 | "id": "opentelemetry-browser-extension@theo.lol" 110 | } 111 | }, 112 | "overrides": { 113 | "firefox": { 114 | "content_security_policy": { 115 | "extension_pages": "script-src 'self'; object-src 'self'; connect-src *; style-src 'self' 'unsafe-inline'; default-src 'self'; " 116 | } 117 | } 118 | } 119 | }, 120 | "overrides": { 121 | "@opentelemetry/instrumentation-xml-http-request": "0.49.1", 122 | "@opentelemetry/instrumentation": "0.49.1", 123 | "@opentelemetry/instrumentation-fetch": "0.49.1" 124 | }, 125 | "pnpm": { 126 | "patchedDependencies": { 127 | "@protobufjs/inquire@1.1.0": "patches/@protobufjs__inquire@1.1.0.patch", 128 | "@opentelemetry/otlp-proto-exporter-base@0.49.1": "patches/@opentelemetry__otlp-proto-exporter-base@0.49.1.patch", 129 | "@opentelemetry/instrumentation-xml-http-request@0.49.1": "patches/@opentelemetry__instrumentation-xml-http-request@0.49.1.patch", 130 | "@opentelemetry/instrumentation-fetch@0.49.1": "patches/@opentelemetry__instrumentation-fetch@0.49.1.patch", 131 | "@opentelemetry/instrumentation-user-interaction@0.35.0": "patches/@opentelemetry__instrumentation-user-interaction@0.35.0.patch" 132 | } 133 | }, 134 | "parcel-resolver-inlinefunc": { 135 | "options": "inlinefunc.config.mjs" 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /patches/@opentelemetry__instrumentation-fetch@0.49.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/build/esm/fetch.js b/build/esm/fetch.js 2 | index 85bcf996de6858076ffff79108e34fead8648abc..55059f843d269d786e077e4b280fa923dd40a345 100644 3 | --- a/build/esm/fetch.js 4 | +++ b/build/esm/fetch.js 5 | @@ -208,10 +208,13 @@ var FetchInstrumentation = /** @class */ (function (_super) { 6 | * @param spanData 7 | * @param response 8 | */ 9 | - FetchInstrumentation.prototype._endSpan = function (span, spanData, response) { 10 | + FetchInstrumentation.prototype._endSpan = function (span, spanData, response, hasError = false) { 11 | var _this = this; 12 | var endTime = core.millisToHrTime(Date.now()); 13 | var performanceEndTime = core.hrTime(); 14 | + if (hasError) { 15 | + span.setStatus({ code: api.SpanStatusCode.ERROR, message: response.statusText }); 16 | + } 17 | this._addFinalSpanAttributes(span, response); 18 | setTimeout(function () { 19 | var _a; 20 | @@ -248,7 +251,7 @@ var FetchInstrumentation = /** @class */ (function (_super) { 21 | status: error.status || 0, 22 | statusText: error.message, 23 | url: url, 24 | - }); 25 | + }, true); 26 | } 27 | function endSpanOnSuccess(span, response) { 28 | plugin._applyAttributesAfterFetch(span, options, response); 29 | diff --git a/build/esnext/fetch.js b/build/esnext/fetch.js 30 | index 37c041395a1467c2f8d268a175448bebe7274db5..76242c2664d4020925c68de845ba4651722e9e39 100644 31 | --- a/build/esnext/fetch.js 32 | +++ b/build/esnext/fetch.js 33 | @@ -189,9 +189,12 @@ export class FetchInstrumentation extends InstrumentationBase { 34 | * @param spanData 35 | * @param response 36 | */ 37 | - _endSpan(span, spanData, response) { 38 | + _endSpan(span, spanData, response, hasError = false) { 39 | const endTime = core.millisToHrTime(Date.now()); 40 | const performanceEndTime = core.hrTime(); 41 | + if (hasError) { 42 | + span.setStatus({ code: api.SpanStatusCode.ERROR, message: response.statusText }); 43 | + } 44 | this._addFinalSpanAttributes(span, response); 45 | setTimeout(() => { 46 | var _a; 47 | @@ -223,7 +226,7 @@ export class FetchInstrumentation extends InstrumentationBase { 48 | status: error.status || 0, 49 | statusText: error.message, 50 | url, 51 | - }); 52 | + }, true); 53 | } 54 | function endSpanOnSuccess(span, response) { 55 | plugin._applyAttributesAfterFetch(span, options, response); 56 | diff --git a/build/src/fetch.js b/build/src/fetch.js 57 | index c7d8915db66653b766780cb39feb592d11d3a488..99b3ed161c8216ffbe09b5df97209e71409067ce 100644 58 | --- a/build/src/fetch.js 59 | +++ b/build/src/fetch.js 60 | @@ -192,9 +192,12 @@ class FetchInstrumentation extends instrumentation_1.InstrumentationBase { 61 | * @param spanData 62 | * @param response 63 | */ 64 | - _endSpan(span, spanData, response) { 65 | + _endSpan(span, spanData, response, hasError = false) { 66 | const endTime = core.millisToHrTime(Date.now()); 67 | const performanceEndTime = core.hrTime(); 68 | + if (hasError) { 69 | + span.setStatus({ code: api.SpanStatusCode.ERROR, message: response.statusText }); 70 | + } 71 | this._addFinalSpanAttributes(span, response); 72 | setTimeout(() => { 73 | var _a; 74 | @@ -226,7 +229,7 @@ class FetchInstrumentation extends instrumentation_1.InstrumentationBase { 75 | status: error.status || 0, 76 | statusText: error.message, 77 | url, 78 | - }); 79 | + }, true); 80 | } 81 | function endSpanOnSuccess(span, response) { 82 | plugin._applyAttributesAfterFetch(span, options, response); 83 | -------------------------------------------------------------------------------- /patches/@opentelemetry__instrumentation-user-interaction@0.35.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/build/esm/instrumentation.js b/build/esm/instrumentation.js 2 | index 928b559ffdafaea1832ec9b6a119656f8f091558..511a277175d888445b8e39e419027fc2f1a19158 100644 3 | --- a/build/esm/instrumentation.js 4 | +++ b/build/esm/instrumentation.js 5 | @@ -63,9 +63,14 @@ var UserInteractionInstrumentation = /** @class */ (function (_super) { 6 | typeof (config === null || config === void 0 ? void 0 : config.shouldPreventSpanCreation) === 'function' 7 | ? config.shouldPreventSpanCreation 8 | : defaultShouldPreventSpanCreation; 9 | + _this.init(); 10 | return _this; 11 | } 12 | - UserInteractionInstrumentation.prototype.init = function () { }; 13 | + UserInteractionInstrumentation.prototype.init = function () { 14 | + this._eventNames.forEach(event => { 15 | + window.addEventListener(event, () => { }); 16 | + }); 17 | + }; 18 | /** 19 | * This will check if last task was timeout and will save the time to 20 | * fix the user interaction when nothing happens 21 | -------------------------------------------------------------------------------- /patches/@opentelemetry__instrumentation-xml-http-request@0.49.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/build/esm/xhr.js b/build/esm/xhr.js 2 | index 5b0ba17e205745073430442e11456237f7be37ad..fc0a59558871758d32e331562c1ae79ccd8bfc46 100644 3 | --- a/build/esm/xhr.js 4 | +++ b/build/esm/xhr.js 5 | @@ -41,6 +41,11 @@ import { AttributeNames } from './enums/AttributeNames'; 6 | // hard to say how long it should really wait, seems like 300ms is 7 | // safe enough 8 | var OBSERVER_WAIT_TIME_MS = 300; 9 | +const MESSAGES = { 10 | + [EventNames.EVENT_TIMEOUT]: 'xhr request timeout', 11 | + [EventNames.EVENT_ABORT]: 'xhr request aborted', 12 | + [EventNames.EVENT_ERROR]: 'xhr request error', 13 | +} 14 | /** 15 | * This class represents a XMLHttpRequest plugin for auto instrumentation 16 | */ 17 | @@ -295,6 +300,9 @@ var XMLHttpRequestInstrumentation = /** @class */ (function (_super) { 18 | } 19 | var span = xhrMem.span, spanUrl = xhrMem.spanUrl, sendStartTime = xhrMem.sendStartTime; 20 | if (span) { 21 | + if (MESSAGES.hasOwnProperty(eventName)) { 22 | + span.setStatus({ code: api.SpanStatusCode.ERROR, message: MESSAGES[eventName] }) 23 | + } 24 | plugin._findResourceAndAddNetworkEvents(xhrMem, span, spanUrl, sendStartTime, performanceEndTime); 25 | span.addEvent(eventName, endTime); 26 | plugin._addFinalSpanAttributes(span, xhrMem, spanUrl); 27 | diff --git a/build/esnext/xhr.js b/build/esnext/xhr.js 28 | index 8ceb9005a8c613fa6cc9819e14492c3cdb0dda61..0a023c0344cde0de3012c6f93c167815d79c5c0b 100644 29 | --- a/build/esnext/xhr.js 30 | +++ b/build/esnext/xhr.js 31 | @@ -26,6 +26,11 @@ import { AttributeNames } from './enums/AttributeNames'; 32 | // hard to say how long it should really wait, seems like 300ms is 33 | // safe enough 34 | const OBSERVER_WAIT_TIME_MS = 300; 35 | +const MESSAGES = { 36 | + [EventNames.EVENT_TIMEOUT]: 'xhr request timeout', 37 | + [EventNames.EVENT_ABORT]: 'xhr request aborted', 38 | + [EventNames.EVENT_ERROR]: 'xhr request error', 39 | +} 40 | /** 41 | * This class represents a XMLHttpRequest plugin for auto instrumentation 42 | */ 43 | @@ -270,6 +275,9 @@ export class XMLHttpRequestInstrumentation extends InstrumentationBase { 44 | } 45 | const { span, spanUrl, sendStartTime } = xhrMem; 46 | if (span) { 47 | + if (MESSAGES.hasOwnProperty(eventName)) { 48 | + span.setStatus({ code: api.SpanStatusCode.ERROR, message: MESSAGES[eventName] }) 49 | + } 50 | plugin._findResourceAndAddNetworkEvents(xhrMem, span, spanUrl, sendStartTime, performanceEndTime); 51 | span.addEvent(eventName, endTime); 52 | plugin._addFinalSpanAttributes(span, xhrMem, spanUrl); 53 | diff --git a/build/src/xhr.js b/build/src/xhr.js 54 | index 4566223315f44c3d9cf32a20a8db60d47321cfc3..b63c435cc646ae244dc0f006e9f4761e4a0d33b7 100644 55 | --- a/build/src/xhr.js 56 | +++ b/build/src/xhr.js 57 | @@ -29,6 +29,11 @@ const AttributeNames_1 = require("./enums/AttributeNames"); 58 | // hard to say how long it should really wait, seems like 300ms is 59 | // safe enough 60 | const OBSERVER_WAIT_TIME_MS = 300; 61 | +const MESSAGES = { 62 | + [EventNames_1.EVENT_TIMEOUT]: 'xhr request timeout', 63 | + [EventNames_1.EVENT_ABORT]: 'xhr request aborted', 64 | + [EventNames_1.EVENT_ERROR]: 'xhr request error', 65 | +} 66 | /** 67 | * This class represents a XMLHttpRequest plugin for auto instrumentation 68 | */ 69 | @@ -273,6 +278,9 @@ class XMLHttpRequestInstrumentation extends instrumentation_1.InstrumentationBas 70 | } 71 | const { span, spanUrl, sendStartTime } = xhrMem; 72 | if (span) { 73 | + if (MESSAGES.hasOwnProperty(eventName)) { 74 | + span.setStatus({ code: api.SpanStatusCode.ERROR, message: MESSAGES[eventName] }) 75 | + } 76 | plugin._findResourceAndAddNetworkEvents(xhrMem, span, spanUrl, sendStartTime, performanceEndTime); 77 | span.addEvent(eventName, endTime); 78 | plugin._addFinalSpanAttributes(span, xhrMem, spanUrl); 79 | -------------------------------------------------------------------------------- /patches/@opentelemetry__otlp-proto-exporter-base@0.49.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/build/esm/platform/browser/index.js b/build/esm/platform/browser/index.js 2 | index 10143b03258cbc82a57a333ff6cb9d944cff05de..2d008438710082bfbeedb7743f667f41172527ac 100644 3 | --- a/build/esm/platform/browser/index.js 4 | +++ b/build/esm/platform/browser/index.js 5 | @@ -15,4 +15,5 @@ 6 | */ 7 | export { OTLPProtoExporterBrowserBase } from './OTLPProtoExporterBrowserBase'; 8 | export { ServiceClientType } from '../types'; 9 | +export { getExportRequestProto } from '../util'; 10 | //# sourceMappingURL=index.js.map 11 | -------------------------------------------------------------------------------- /patches/@protobufjs__inquire@1.1.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/.npmignore b/.npmignore 2 | deleted file mode 100644 3 | index ce75de45b2b691d0fccbd067e2e3104bcd113d6a..0000000000000000000000000000000000000000 4 | diff --git a/index.js b/index.js 5 | index 33778b5539b7fcd7a1e99474a4ecb1745fdfe508..e9e257fe11122366b382699ed29ab76f1b81a255 100644 6 | --- a/index.js 7 | +++ b/index.js 8 | @@ -9,9 +9,9 @@ module.exports = inquire; 9 | */ 10 | function inquire(moduleName) { 11 | try { 12 | - var mod = eval("quire".replace(/^/,"re"))(moduleName); // eslint-disable-line no-eval 13 | + var mod = require(moduleName); // eslint-disable-line no-eval 14 | if (mod && (mod.length || Object.keys(mod).length)) 15 | return mod; 16 | - } catch (e) {} // eslint-disable-line no-empty 17 | + } catch (e) { } // eslint-disable-line no-empty 18 | return null; 19 | } 20 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-preset-mantine': {}, 4 | 'postcss-simple-vars': { 5 | variables: { 6 | 'mantine-breakpoint-xs': '36em', 7 | 'mantine-breakpoint-sm': '48em', 8 | 'mantine-breakpoint-md': '62em', 9 | 'mantine-breakpoint-lg': '75em', 10 | 'mantine-breakpoint-xl': '88em', 11 | }, 12 | }, 13 | }, 14 | }; -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | // Import (with side-effects) which initializes all extension listeners 2 | // TODO: Modify intialization to be more testable 3 | import '~listeners' 4 | -------------------------------------------------------------------------------- /src/components/ColorModeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { Switch, rem, useComputedColorScheme, useMantineColorScheme, useMantineTheme } from "@mantine/core"; 2 | import { IconMoonStars, IconSun } from "@tabler/icons-react"; 3 | 4 | export default function ColorModeSwitch(props: any) { 5 | const theme = useMantineTheme(); 6 | const { setColorScheme } = useMantineColorScheme({ keepTransitions: true }); 7 | const computedColorScheme = useComputedColorScheme('dark'); 8 | 9 | const sunIcon = ( 10 | 15 | ); 16 | 17 | const moonIcon = ( 18 | 23 | ); 24 | 25 | return { setColorScheme(event.currentTarget.checked ? 'dark' : 'light') }} {...props} />; 26 | } -------------------------------------------------------------------------------- /src/components/Configuration.css: -------------------------------------------------------------------------------- 1 | .configuration-container { 2 | margin: 1rem; 3 | margin-bottom: 0; 4 | padding-right: 0; 5 | padding-bottom: 0; 6 | padding-top: 0; 7 | min-height: 402px; 8 | } -------------------------------------------------------------------------------- /src/components/Configuration.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ActionIcon, 3 | Affix, 4 | Box, 5 | Button, 6 | Fieldset, 7 | Group, 8 | ScrollArea, 9 | Stack, 10 | Switch, 11 | Text, 12 | Tooltip, 13 | rem 14 | } from "@mantine/core" 15 | import './Configuration.css' 16 | import { IconBraces, IconDeviceFloppy, IconFileCheck, IconPower } from "@tabler/icons-react" 17 | import { IconSettings } from "@tabler/icons-react" 18 | import TraceConfiguration from "~components/TraceConfiguration" 19 | import LogConfiguration from "~components/LogConfiguration" 20 | import GeneralConfiguration from "~components/GeneralConfiguration" 21 | import { useLocalStorage } from "~hooks/storage" 22 | import { useEffect, useRef, useState } from "react" 23 | import { ConfigMode } from "~storage/local/internal" 24 | import { de } from "~utils/serde" 25 | import { UserFacingConfiguration } from "~storage/local/configuration" 26 | import { syncMatchPatternPermissions } from "~utils/match-pattern" 27 | import { consoleProxy } from "~utils/logging" 28 | import { usePlatformInfo } from "~hooks/platform" 29 | import { toPlatformSpecificKeys } from "~utils/platform" 30 | import type { EditorView } from "@codemirror/view" 31 | import type { EditorState } from "@codemirror/state" 32 | 33 | // TODO: Consider replacing "Configuration" header with a menu 34 | export default function Configuration() { 35 | const [{ enabled, configMode, matchPatterns, configText, editorState }, setLocalStorage] = useLocalStorage(["enabled", "configMode", "matchPatterns", "configText", "editorState"]) 36 | const [editorText, setEditorText] = useState(editorState?.doc as string | undefined) 37 | const [editorReady, setEditorReady] = useState(false) 38 | const editorDirty = editorText && editorState && editorReady && editorText !== configText 39 | const portalTargetRef = useRef() 40 | const [refsInitialized, setRefsInitialized] = useState(false) 41 | const showAffix = refsInitialized && (editorReady || configMode == ConfigMode.Visual) 42 | const platformInfo = usePlatformInfo() 43 | const saveKeys = toPlatformSpecificKeys(['Mod', 'S'], platformInfo) 44 | 45 | const configModeToggle = () => { 46 | // toggle config mode 47 | setLocalStorage({ configMode: configMode === ConfigMode.Visual ? ConfigMode.Code : ConfigMode.Visual }) 48 | } 49 | 50 | useEffect(() => { 51 | if (portalTargetRef.current) { 52 | setRefsInitialized(true) 53 | } 54 | }, [portalTargetRef]) 55 | 56 | useEffect(() => { 57 | if (editorText == null && editorState && editorState.doc) { 58 | setEditorText(editorState.doc as string) 59 | } 60 | }, [editorState]) 61 | 62 | const checkMatchPatterns = async (text: string) => { 63 | try { 64 | const newConfig = de(text, UserFacingConfiguration); 65 | 66 | if (newConfig.matchPatterns !== matchPatterns) { 67 | await syncMatchPatternPermissions({ prev: matchPatterns || [], next: newConfig.matchPatterns }); 68 | } 69 | } catch (e) { 70 | consoleProxy.error(e); 71 | } 72 | } 73 | 74 | const onEditorChange = (text: string) => { 75 | setEditorText(text); 76 | } 77 | 78 | const onEditorSave = async (text: string) => { 79 | try { 80 | await checkMatchPatterns(text); 81 | await setLocalStorage({ configText: text }) 82 | } catch (e) { 83 | consoleProxy.error(e); 84 | } 85 | } 86 | 87 | const onEditorReady = (view: EditorView, state: EditorState) => { 88 | setEditorReady(true); 89 | } 90 | 91 | return ( 92 | 93 | 94 | 95 | 96 | Configuration 97 | 98 | setLocalStorage({ enabled: event.currentTarget.checked })} 102 | size='md' 103 | aria-label='Enable or disable the extension' 104 | thumbIcon={ 105 | enabled ? ( 106 | 111 | ) : ( 112 | 116 | ) 117 | } 118 | /> 119 | 120 |
134 | {portalTargetRef?.current && showAffix && 137 | 138 | 141 | 142 | } 143 | { 144 | showAffix && configMode == ConfigMode.Code && editorDirty && 145 | 149 | 154 | { onEditorSave(editorText) }}> 155 | 156 | 157 | 158 | 159 | } 160 | 161 | 162 | 163 | 168 | {configMode === ConfigMode.Visual && 169 | <> 170 | 171 | 172 | 173 | } 174 | 175 | 176 |
177 |
178 | 179 | ) 180 | } -------------------------------------------------------------------------------- /src/components/Editor/hover.ts: -------------------------------------------------------------------------------- 1 | import md from "markdown-it"; 2 | import type { FoundCursorData } from "codemirror-json-schema/dist/features/hover"; 3 | import { parser } from "@lezer/json" 4 | import { HighlightStyle } from "@codemirror/language" 5 | import { highlightCode, Tag, tags } from "@lezer/highlight" 6 | 7 | const renderer = md({ 8 | linkify: true, 9 | typographer: true, 10 | }); 11 | 12 | var defaultRender = renderer.renderer.rules.link_open || function (tokens, idx, options, env, self) { 13 | return self.renderToken(tokens, idx, options); 14 | }; 15 | 16 | renderer.renderer.rules.link_open = function (tokens, idx, options, env, self) { 17 | // Add a new `target` attribute, or replace the value of the existing one. 18 | tokens[idx].attrSet('target', '_blank'); 19 | 20 | // Pass the token to the default renderer. 21 | return defaultRender(tokens, idx, options, env, self); 22 | }; 23 | 24 | export const renderExample = (jsonExample: any, highlighter: HighlightStyle) => { 25 | let result = document.createElement("pre") 26 | 27 | function emit(text, classes) { 28 | let node = document.createTextNode(text) 29 | if (classes) { 30 | let span = document.createElement("span") 31 | span.appendChild(node) 32 | span.className = classes 33 | // @ts-ignore 34 | node = span 35 | } 36 | result.appendChild(node) 37 | } 38 | 39 | function emitBreak() { 40 | result.appendChild(document.createTextNode("\n")) 41 | } 42 | 43 | highlightCode(jsonExample, parser.parse(jsonExample), highlighter, 44 | emit, emitBreak) 45 | return result 46 | } 47 | 48 | export const formatHover = (highlighter: HighlightStyle): (data: FoundCursorData) => HTMLElement => { 49 | 50 | return (data: FoundCursorData) => { 51 | const { schema, pointer } = data; 52 | const key = pointer.split("/").pop() || ""; 53 | 54 | const div = document.createElement("div"); 55 | div.className = "cm-json-schema-hover"; 56 | 57 | // Create key and type info 58 | const keyAndType = document.createElement("div"); 59 | keyAndType.className = "cm-json-schema-hover-key-and-type"; 60 | 61 | const keySpan = document.createElement("span"); 62 | keySpan.className = "cm-json-schema-hover-key"; 63 | keySpan.textContent = `${key}: `; 64 | keyAndType.appendChild(keySpan); 65 | 66 | let keyClasses = highlighter.style([tags.propertyName]) 67 | 68 | if (keyClasses) { 69 | keySpan.className += " " + keyClasses 70 | } 71 | 72 | const typeSpan = document.createElement("span"); 73 | typeSpan.textContent = schema.type; 74 | typeSpan.className = "cm-json-schema-hover-type"; 75 | keyAndType.appendChild(typeSpan); 76 | let typeTags: Tag[] = [] 77 | 78 | switch (schema.type) { 79 | case "object": 80 | typeTags.push(tags.brace) 81 | break; 82 | case "array": 83 | typeTags.push(tags.bracket) 84 | break; 85 | case "string": 86 | typeTags.push(tags.string) 87 | break; 88 | case "number": 89 | typeTags.push(tags.number) 90 | break; 91 | case "boolean": 92 | typeTags.push(tags.bool) 93 | break; 94 | case "null": 95 | typeTags.push(tags.null) 96 | break; 97 | } 98 | 99 | const classes = highlighter.style(typeTags) 100 | 101 | if (classes) { 102 | typeSpan.className += " " + classes 103 | } 104 | 105 | div.appendChild(keyAndType); 106 | 107 | // Create description (if available) 108 | if (schema.description) { 109 | const description = document.createElement("div"); 110 | description.className = "cm-json-schema-hover-description"; 111 | 112 | const content = renderer.render(schema.description || ""); 113 | description.innerHTML = content; 114 | div.appendChild(description); 115 | } 116 | 117 | // Create examples (if available) 118 | if (schema.examples) { 119 | const examples = document.createElement("div"); 120 | examples.className = "cm-json-schema-hover-examples"; 121 | examples.textContent = "Example:" 122 | schema.examples?.forEach((example: object) => { 123 | const jsonExample = JSON.stringify({ [key]: example }, null, 2); 124 | const node = renderExample(jsonExample, highlighter); 125 | examples.appendChild(node); 126 | }) 127 | div.appendChild(examples); 128 | } 129 | return div 130 | } 131 | } 132 | 133 | export const getHoverTexts = (data: FoundCursorData): FoundCursorData => { 134 | // since we override formatHover, we can return the data as is and reuse it (instead of the default behavior) 135 | return data; 136 | } -------------------------------------------------------------------------------- /src/components/Editor/index.css: -------------------------------------------------------------------------------- 1 | /* TODO: technically these should all be their own JS theme styles, instead of a CSS file */ 2 | .cm-editor { 3 | background-color: var(--mantine-color-body); 4 | } 5 | 6 | .cm-editor .cm-content { 7 | padding: 0; 8 | } 9 | 10 | .cm-editor .cm-gutters { 11 | background-color: var(--mantine-color-body); 12 | } 13 | 14 | .cm-editor.cm-focused { 15 | outline: none; 16 | } 17 | 18 | .cm-tooltip pre { 19 | margin: 0; 20 | } 21 | 22 | .cm-tooltip p:last-of-type { 23 | margin-block-end: 0; 24 | } 25 | 26 | .cm-tooltip.cm-tooltip-hover { 27 | background-color: var(--mantine-color-body); 28 | font-family: monospace; 29 | color: var(--mantine-color-text); 30 | border: 1px solid var(--mantine-color-default-border); 31 | z-index: 1000; 32 | } 33 | 34 | .cm-json-schema-hover { 35 | padding: 1rem; 36 | overflow: auto; 37 | max-height: 100%; 38 | max-width: 100%; 39 | } 40 | 41 | .cm-json-schema-hover-examples { 42 | margin-top: 1rem; 43 | } 44 | 45 | /* Selection and active line style fixes */ 46 | 47 | .cm-line>.cm-selectionMatch { 48 | background-color: var(--mantine-primary-color-light-hover); 49 | } 50 | 51 | .cm-layer.cm-selectionLayer>.cm-selectionBackground { 52 | background: var(--mantine-primary-color-light-hover) !important; 53 | } 54 | 55 | :root[data-mantine-color-scheme="light"] { 56 | .cm-line.cm-activeLine { 57 | background-color: #0000000f; 58 | } 59 | } 60 | 61 | /* Completion tooltip fixes */ 62 | 63 | .cm-tooltip>.cm-tooltip.cm-completionInfo { 64 | overflow-wrap: break-word; 65 | background-color: var(--mantine-color-body); 66 | border: 1px solid var(--mantine-color-default-border); 67 | } 68 | 69 | .cm-editor .cm-tooltip.cm-tooltip.cm-tooltip-autocomplete>ul { 70 | background-color: var(--mantine-color-body); 71 | border: 1px solid var(--mantine-color-default-border); 72 | } 73 | 74 | /* Find and replace fixes */ 75 | 76 | .cm-panels.cm-panels-bottom { 77 | position: fixed; 78 | background-color: var(--mantine-color-body); 79 | border-top: 1px solid var(--mantine-color-default-border); 80 | } -------------------------------------------------------------------------------- /src/components/Editor/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, useComputedColorScheme } from "@mantine/core"; 2 | import { EditorState, useCodeMirror } from "@uiw/react-codemirror"; 3 | import { useEffect, useRef, useState } from "react"; 4 | import { getLocalStorage, setLocalStorage } from "~storage/local"; 5 | import { linter, lintKeymap, lintGutter } from "@codemirror/lint"; 6 | import { 7 | EditorView, hoverTooltip, keymap, ViewUpdate, gutter, 8 | drawSelection, 9 | highlightActiveLineGutter, 10 | } from "@codemirror/view"; 11 | import { json, jsonParseLinter, jsonLanguage } from "@codemirror/lang-json"; 12 | import { 13 | completionKeymap, 14 | closeBrackets, 15 | closeBracketsKeymap, 16 | } from "@codemirror/autocomplete"; 17 | import schema from "~generated/schemas/configuration.schema.json"; 18 | 19 | import { 20 | jsonSchemaLinter, 21 | jsonSchemaHover, 22 | jsonCompletion, 23 | stateExtensions, 24 | handleRefresh, 25 | } from "codemirror-json-schema"; 26 | import { getHoverTexts, formatHover } from "~components/Editor/hover"; 27 | import { themeDark, themeLight } from "./theme"; 28 | import './index.css'; 29 | import { defaultKeymap, historyKeymap, historyField } from "@codemirror/commands"; 30 | import { HighlightStyle, indentOnInput, bracketMatching, foldKeymap } from "@codemirror/language"; 31 | import { consoleProxy } from "~utils/logging"; 32 | 33 | const stateFields = { history: historyField }; 34 | const constExtensions = [ 35 | EditorView.lineWrapping, 36 | lintGutter(), 37 | gutter({ class: "CodeMirror-lint-markers" }), 38 | bracketMatching(), 39 | highlightActiveLineGutter(), 40 | closeBrackets(), // TODO: figure out why brackets not closing when started before comma 41 | indentOnInput(), 42 | drawSelection(), 43 | keymap.of([ 44 | ...closeBracketsKeymap, 45 | ...defaultKeymap, 46 | ...historyKeymap, 47 | ...foldKeymap, 48 | ...completionKeymap, 49 | ...lintKeymap, 50 | ]), 51 | json(), 52 | linter(jsonParseLinter(), { 53 | // default is 750ms 54 | delay: 300 55 | }), 56 | linter(jsonSchemaLinter(), { 57 | needsRefresh: handleRefresh, 58 | }), 59 | jsonLanguage.data.of({ 60 | autocomplete: jsonCompletion(), // TODO better autocomplete 61 | }), 62 | ] 63 | 64 | export type EditorProps = { 65 | visible: boolean; 66 | defaultValue: string; 67 | onSave?: (text: string) => void; 68 | onChange?: (text: string) => void; 69 | onEditorReady?: (view: EditorView, state: EditorState) => void; 70 | } 71 | 72 | // TODO: fix ctrl+f search styling 73 | // TODO: fix autocomplete typing option background color flicker 74 | export const Editor = ({ defaultValue, visible, onSave, onChange, onEditorReady }: EditorProps) => { 75 | const computedColorScheme = useComputedColorScheme('dark'); 76 | const editor = useRef(null); 77 | const [initialEditorState, setInitialEditorState] = useState(null); 78 | const [renderedConfig, setRenderedConfig] = useState(defaultValue); 79 | const theme = computedColorScheme == 'dark' ? themeDark : themeLight 80 | // second element returned by createTheme is the syntaxHighlighting extension 81 | const highlighter = theme[1].find(item => item.value instanceof HighlightStyle)?.value; 82 | 83 | const onEditorChange = async (val: string, viewUpdate: ViewUpdate) => { 84 | consoleProxy.debug('editor change', val, viewUpdate); 85 | setRenderedConfig(val); 86 | onChange && onChange(val); 87 | 88 | const state = viewUpdate.state.toJSON(stateFields); 89 | await setLocalStorage({ editorState: state }); 90 | } 91 | 92 | const onEditorSave = () => { 93 | onSave && onSave(renderedConfig); 94 | return true; 95 | } 96 | 97 | const codemirror = useCodeMirror({ 98 | container: editor.current, 99 | initialState: initialEditorState ? { 100 | json: initialEditorState, 101 | fields: stateFields, 102 | } : undefined, 103 | extensions: [ 104 | constExtensions, 105 | // @ts-ignore 106 | hoverTooltip(jsonSchemaHover({ getHoverTexts, formatHover: formatHover(highlighter) })), 107 | // @ts-ignore 108 | stateExtensions(schema), 109 | keymap.of([ 110 | { key: "Mod-s", run: onEditorSave }, 111 | ]), 112 | ], 113 | theme, 114 | onCreateEditor(view, state) { 115 | onEditorReady && onEditorReady(view, state); 116 | consoleProxy.debug('editor ready', view, state); 117 | }, 118 | onChange: onEditorChange, 119 | value: renderedConfig, 120 | height: '100%', 121 | }); 122 | 123 | // TODO: seems like we should be able to supply the initial state directly to the editor instead 124 | useEffect(() => { 125 | const init = async () => { 126 | if (initialEditorState == null) { 127 | const { editorState, configText } = await getLocalStorage(['editorState', 'configText']) 128 | // @ts-ignore TODO: fix this 129 | setRenderedConfig(editorState?.doc || configText); 130 | // @ts-ignore TODO: fix this 131 | setInitialEditorState(editorState); 132 | } 133 | } 134 | init(); 135 | }, [initialEditorState]) 136 | 137 | useEffect(() => { 138 | initialEditorState && codemirror.setState(initialEditorState); 139 | editor.current && codemirror.setContainer(editor.current); 140 | }, [editor.current, initialEditorState]) 141 | 142 | useEffect(() => { 143 | if (defaultValue !== renderedConfig) { 144 | setRenderedConfig(defaultValue); 145 | } 146 | }, [defaultValue]) 147 | 148 | return ( 149 | 150 | ) 151 | }; -------------------------------------------------------------------------------- /src/components/Editor/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@uiw/codemirror-themes' 2 | import { vscodeDarkStyle, vscodeLightStyle, defaultSettingsVscodeDark, defaultSettingsVscodeLight } from "@uiw/codemirror-theme-vscode"; 3 | 4 | export const themeDark = createTheme({ theme: 'dark', settings: defaultSettingsVscodeDark, styles: vscodeDarkStyle }); 5 | export const themeLight = createTheme({ theme: 'light', settings: defaultSettingsVscodeLight, styles: vscodeLightStyle }); 6 | -------------------------------------------------------------------------------- /src/components/GeneralConfiguration.tsx: -------------------------------------------------------------------------------- 1 | import { Anchor, Fieldset, Group, Text } from "@mantine/core"; 2 | import { TagsInput } from "~components/TagsInput"; 3 | import ColorModeSwitch from "~components/ColorModeSwitch"; 4 | import { useLocalStorage } from "~hooks/storage"; 5 | import { defaultOptions } from "~utils/options"; 6 | import { syncMatchPatternPermissions } from "~utils/match-pattern"; 7 | import { ConfigMode, type MatchPatternError } from "~storage/local/internal"; 8 | import { Editor } from "~components/Editor"; 9 | import { KeyValueInput } from "~components/KeyValueInput"; 10 | import { ErrorBoundary } from "react-error-boundary"; 11 | import type { EditorView } from "@codemirror/view"; 12 | import type { EditorState } from "@codemirror/state"; 13 | 14 | const patternErrorsToPills = (patterns?: string[], errors?: MatchPatternError[]): Map => { 15 | const map = new Map() 16 | errors?.forEach((error) => { 17 | const index = patterns?.indexOf(error.pattern) || -1 18 | if (index !== -1) { 19 | map.set(index, error.error) 20 | } 21 | }) 22 | return map 23 | } 24 | 25 | type GeneralConfigurationProps = { 26 | enabled: boolean 27 | onEditorSave: (text: string) => void 28 | onEditorChange: (text: string) => void 29 | onEditorReady: (view: EditorView, state: EditorState) => void 30 | } 31 | 32 | export default function GeneralConfiguration({ enabled, onEditorSave, onEditorChange, onEditorReady }: GeneralConfigurationProps) { 33 | const [{ 34 | configMode, 35 | configText, 36 | matchPatternErrors, 37 | matchPatterns, 38 | attributes, 39 | headers 40 | }, setLocalStorage] = useLocalStorage([ 41 | 'configText', 42 | 'matchPatterns', 43 | 'matchPatternErrors', 44 | 'configMode', 45 | 'attributes', 46 | 'headers' 47 | ]) 48 | const pillErrors = patternErrorsToPills(matchPatterns, matchPatternErrors) 49 | 50 | const onEnabledUrlsChange = async (values: string[]) => { 51 | setLocalStorage({ matchPatterns: values }) 52 | syncMatchPatternPermissions({ prev: matchPatterns || [], next: values }) 53 | } 54 | 55 | return ( 56 |
67 | 72 | 73 | }> 74 | {configMode === ConfigMode.Visual && 75 | 76 | { 80 | const newPatterns = [...(matchPatterns || [])] 81 | newPatterns.splice(index, 1) 82 | onEnabledUrlsChange(newPatterns) 83 | }} 84 | onValueAdded={(value) => { 85 | 86 | if (matchPatterns) { 87 | matchPatterns.push(value) 88 | onEnabledUrlsChange(matchPatterns) 89 | } 90 | }} 91 | label={ 92 | <> 93 | Allow extension on {" "} 94 | 95 | } 96 | disabled={!enabled} 97 | description={ 98 | <> 99 | Choose webpages which should be instrumented, specified as a list of {" "} 100 | 104 | match patterns 105 | . ⚠️ Adding new entries will require reloading targeted pages. 106 | 107 | } 108 | placeholder={matchPatterns?.length == 0 ? defaultOptions.matchPatterns.join(', ') : ''} 109 | delimiter={","} 110 | /> 111 | {attributes && await setLocalStorage({ attributes })} 114 | label="Resource attributes" 115 | disabled={!enabled} 116 | description={<>Attach additional attributes on all exported logs/traces.} 117 | tableProps={{ 118 | withRowBorders: false, 119 | withColumnBorders: true, 120 | }} 121 | keyPlaceholder="key" 122 | valuePlaceholder="value" 123 | fullWidth 124 | />} 125 | {headers && await setLocalStorage({ headers })} 128 | label="Request headers" 129 | disabled={!enabled} 130 | description="Include additional HTTP headers on all export requests." 131 | tableProps={{ 132 | withRowBorders: false, 133 | withColumnBorders: true, 134 | }} 135 | keyPlaceholder="key" 136 | valuePlaceholder="value" 137 | fullWidth 138 | />} 139 | 140 | } 141 | shucks, looks like the editor is having an issue}> 142 | 148 | 149 |
150 | ); 151 | } -------------------------------------------------------------------------------- /src/components/KeyShortcut.tsx: -------------------------------------------------------------------------------- 1 | import { Group, Kbd } from "@mantine/core"; 2 | import { usePlatformInfo } from "~hooks/platform" 3 | import { toPlatformSpecificKeys } from "~utils/platform"; 4 | 5 | export type KeyShortcutProps = { 6 | keys: string[] 7 | } 8 | 9 | export const KeyShortcut = ({ keys }: KeyShortcutProps) => { 10 | const platformInfo = usePlatformInfo(); 11 | const platformKeys = platformInfo ? toPlatformSpecificKeys(keys, platformInfo) ?? keys : keys; 12 | const elements = platformKeys.map((key, i) => 13 | <> 14 | {key} 15 | {(i !== platformKeys.length - 1) && '+'} 16 | 17 | ); 18 | 19 | return ( 20 | {elements} 21 | ) 22 | } -------------------------------------------------------------------------------- /src/components/KeyValueInput/KeyValueRow.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon, Table, TextInput } from "@mantine/core" 2 | import { IconTrash } from "@tabler/icons-react" 3 | 4 | export type KeyValueRowProps = { 5 | _key: string 6 | value: string 7 | disabled?: boolean 8 | onChange: (newKey: string, newValue: string) => void 9 | onRemove: () => void 10 | keyPlaceholder?: string 11 | valuePlaceholder?: string 12 | } 13 | 14 | // TODO: backspace on the first input should remove the row if all inputs are empty 15 | // TODO: backspace should focus the previous input if the current input is empty 16 | export const KeyValueRow = ({ _key: key, value, onChange, onRemove, disabled, keyPlaceholder, valuePlaceholder }: KeyValueRowProps) => { 17 | 18 | const keyOnChange = (event: React.ChangeEvent) => { 19 | onChange(event.currentTarget.value, value) 20 | } 21 | 22 | const valueOnChange = (event: React.ChangeEvent) => { 23 | onChange(key, event.currentTarget.value) 24 | } 25 | 26 | return ( 27 | 28 | 29 | 34 | 35 | 36 | 37 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ) 52 | } -------------------------------------------------------------------------------- /src/components/KeyValueInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Table, Text, type TableProps } from "@mantine/core" 2 | import { useEffect, useState, type ReactNode } from "react" 3 | import { KeyValueRow } from "~components/KeyValueInput/KeyValueRow" 4 | 5 | export type KeyValueInputProps = { 6 | defaultValue: Map 7 | onChange: (value: Map) => void 8 | label?: string | ReactNode 9 | description?: string | ReactNode 10 | disabled?: boolean 11 | tableProps: TableProps 12 | keyPlaceholder?: string 13 | valuePlaceholder?: string 14 | fullWidth?: boolean 15 | } 16 | 17 | export type Row = { 18 | id: string 19 | key: string 20 | value: string 21 | } 22 | 23 | const withEmptyRow = (rows: Row[]): Row[] => { 24 | const newRows = [...rows]; 25 | const lastRow = rows.length > 0 ? rows[rows.length - 1] : { key: null, value: null }; 26 | 27 | if (rows.length == 0 || lastRow.key) { 28 | newRows.push({ id: generateUniqueId(), key: '', value: '' }); 29 | } 30 | return newRows; 31 | } 32 | 33 | // TODO: consider better way to generate ids than this copilot generated one 34 | const generateUniqueId = () => '_' + Math.random().toString(36).substring(2, 9); 35 | 36 | /** 37 | * A component that allows the user to input key-value pairs. 38 | * (a wrapper around Table that allows for adding/removing rows, key/value columns, and editable cells) 39 | */ 40 | export const KeyValueInput = ({ defaultValue, onChange, label, description, disabled, tableProps, keyPlaceholder, valuePlaceholder, fullWidth }: KeyValueInputProps) => { 41 | const [rows, setRows] = useState( 42 | withEmptyRow(Array.from(defaultValue).map(([key, val]) => ({ id: generateUniqueId(), key, value: val } as Row))) 43 | ); 44 | 45 | useEffect(() => { 46 | const newMap = new Map(rows.filter(({ key, value }) => (key || value)).map(({ key, value }) => [key, value])); 47 | 48 | if (newMap.size === defaultValue.size && Array.from(newMap).every(([key, value]) => defaultValue.has(key) && defaultValue.get(key) === value)) { 49 | return; 50 | } 51 | onChange(newMap); 52 | }, [rows]) 53 | 54 | const rowOnChange = (id: string) => { 55 | return (newKey: string, newValue: string) => { 56 | const existingKeyIndex = rows.findIndex(row => row.key === newKey); 57 | let updatedRows = rows.map(row => 58 | row.id === id ? { ...row, key: newKey, value: newValue } : row 59 | ); 60 | 61 | if (existingKeyIndex > -1 && rows[existingKeyIndex].id !== id) { 62 | updatedRows = updatedRows.filter((row, i) => i !== existingKeyIndex); 63 | } 64 | setRows(withEmptyRow(updatedRows)); 65 | } 66 | }; 67 | 68 | const onRemove = (id: string) => { 69 | const updatedRows = rows.filter(row => row.id !== id); 70 | setRows(withEmptyRow(updatedRows)); 71 | }; 72 | 73 | return ( 74 | 75 | 76 | {label} 77 | {description} 78 | 79 | 80 | 85 | {rows.map(({ id, key, value: val }, i) => onRemove(id)} keyPlaceholder={keyPlaceholder} valuePlaceholder={valuePlaceholder} />)} 86 | 87 |
88 |
89 | ) 90 | } -------------------------------------------------------------------------------- /src/components/LinkSection.css: -------------------------------------------------------------------------------- 1 | .link-section { 2 | padding: 0 1rem 1rem 1rem; 3 | 4 | .link-section-links { 5 | .link-section-icon { 6 | &>svg { 7 | padding: 2px; 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/components/LinkSection.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon, Anchor, Flex, Group, Text, Tooltip } from "@mantine/core"; 2 | import { IconBrandGithubFilled, IconCoffee, IconPalette } from "@tabler/icons-react"; 3 | 4 | import './LinkSection.css' 5 | import { config } from "~config"; 6 | 7 | type LinkSectionIconProps = { 8 | label: string, 9 | href: string, 10 | icon: JSX.Element 11 | tooltip: string, 12 | variant?: string, 13 | endElement?: JSX.Element 14 | } 15 | 16 | function LinkSectionIcon({ label, icon, href, tooltip, variant, endElement }: LinkSectionIconProps) { 17 | return ( 18 | 19 | 20 | 21 | {icon} 22 | 23 | {endElement} 24 | 25 | 26 | ) 27 | } 28 | 29 | export default function LinkSection() { 30 | 31 | const links = [ 32 | { 33 | label: 'Link to projects GitHub', 34 | href: 'https://github.com/tbrockman/browser-extension-for-opentelemetry', 35 | icon: , 36 | tooltip: 'github' 37 | }, 38 | { 39 | label: 'Link to project maintainer\'s website', 40 | href: 'https://theo.lol', 41 | icon: , 42 | tooltip: 'creator' 43 | }, 44 | ] 45 | 46 | if (process.env.PLASMO_BROWSER !== 'safari') { 47 | links.push({ 48 | label: 'Link to sponsor project maintainer', 49 | href: 'https://github.com/sponsors/tbrockman', 50 | icon: , 51 | tooltip: 'sponsorship' 52 | }) 53 | } 54 | 55 | const otel = { 56 | label: 'Link to OpenTelemetry website', 57 | href: 'https://opentelemetry.io', 58 | icon: 59 | 60 | 61 | , 62 | tooltip: 'opentelemetry.io', 63 | variant: 'transparent', 64 | endElement: 65 | Open 66 | Telemetry 67 | 68 | } 69 | 70 | return ( 71 | 72 | 73 | 74 | 75 | 76 | {links.map((link) => )} 77 | 78 | 79 | ) 80 | } -------------------------------------------------------------------------------- /src/components/LogConfiguration.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, Fieldset, Group, Text, TextInput, type CheckboxProps } from "@mantine/core"; 2 | import { IconTerminal } from "@tabler/icons-react"; 3 | import { useCallback, useEffect, useRef } from "react"; 4 | import { useLocalStorage } from "~hooks/storage"; 5 | import { defaultOptions } from "~utils/options"; 6 | 7 | const LogsIcon: CheckboxProps['icon'] = ({ ...others }) => 8 | ; 9 | 10 | type LogConfigurationProps = { 11 | enabled: boolean 12 | } 13 | 14 | export default function LogConfiguration({ enabled }: LogConfigurationProps) { 15 | const [{ logCollectorUrl, loggingEnabled }, setLocalStorage] = useLocalStorage(["logCollectorUrl", "loggingEnabled"]) 16 | const checkboxRef = useRef(null); 17 | 18 | const toggleDisabled = useCallback(() => { 19 | setLocalStorage({ loggingEnabled: !loggingEnabled }) 20 | }, [loggingEnabled]); 21 | // Hack for Firefox disabled fieldset checkbox event handling 22 | // see: https://stackoverflow.com/questions/63740106/checkbox-onchange-in-legend-inside-disabled-fieldset-not-firing-in-firefox-w 23 | useEffect(() => { 24 | checkboxRef.current?.addEventListener('change', toggleDisabled) 25 | 26 | return () => { 27 | checkboxRef.current?.removeEventListener('change', toggleDisabled) 28 | } 29 | }, [toggleDisabled]) 30 | 31 | return ( 32 |
40 | {loggingEnabled !== undefined && { }} 46 | size="lg" 47 | variant='outline' 48 | aria-label='Enable or disable exporting logs' 49 | styles={{ 50 | labelWrapper: { 51 | justifyContent: 'center' 52 | } 53 | }} 54 | label={ 55 | Logging 56 | } 57 | />} 58 | 59 | } disabled={!loggingEnabled}> 60 | {logCollectorUrl !== undefined && 64 | Choose where to send Protobuf-encoded OTLP logs over HTTP. 65 | 66 | } 67 | placeholder={defaultOptions.logCollectorUrl} 68 | value={logCollectorUrl} 69 | onChange={async (event) => { 70 | await setLocalStorage({logCollectorUrl: event.currentTarget.value}) 71 | }} 72 | />} 73 |
74 | ); 75 | } -------------------------------------------------------------------------------- /src/components/TagsInput.tsx: -------------------------------------------------------------------------------- 1 | import { Combobox, Pill, PillsInput, Tooltip, useCombobox } from "@mantine/core"; 2 | import { useClickOutside } from "@mantine/hooks"; 3 | import { IconExclamationCircle } from "@tabler/icons-react"; 4 | import { useEffect, useState, type ReactNode } from "react"; 5 | 6 | type PillErrorMap = Map 7 | type TagsInputProps = { 8 | delimiter: string 9 | description: string | React.ReactNode 10 | disabled?: boolean 11 | error?: string 12 | errors?: PillErrorMap 13 | label: string | React.ReactNode 14 | placeholder: string 15 | value: string[] 16 | onValueRemoved?: (index: number) => void 17 | onValueAdded?: (value: string) => void; 18 | onTagSelected?: (index: number) => void 19 | } 20 | 21 | export const TagsInput = ({ delimiter, description, disabled, errors, label, placeholder, value, onValueRemoved, onValueAdded, onTagSelected }: TagsInputProps) => { 22 | const [selectedIndex, setSelectedIndex] = useState(-1); 23 | const [pillInputValue, setPillInputValue] = useState(''); 24 | const handleClickOutside = () => setSelectedIndex(-1); 25 | const ref = useClickOutside(handleClickOutside, ['mousedown', 'touchstart', 'focusin']); 26 | const combobox = useCombobox({}); 27 | 28 | const elements = value.map((val: string, i: number) => { 29 | const hasError = errors?.has(i); 30 | const error = errors?.get(i); 31 | const styles = { 32 | root: { 33 | ...(hasError && { 34 | backgroundColor: 'var(--mantine-color-error)', 35 | color: 'var(--mantine-color-white)', 36 | }), 37 | ...(i == selectedIndex && { 38 | outline: '2px solid var(--mantine-primary-color-filled)', 39 | outlineOffset: 'calc(.125rem* var(--mantine-scale))' 40 | }), 41 | }, 42 | label: { 43 | display: 'flex', 44 | alignItems: 'center', 45 | gap: '4px' 46 | } 47 | } 48 | const inner = 49 | { 56 | event.preventDefault(); 57 | event.currentTarget.focus(); 58 | }} 59 | onKeyDown={(event) => { 60 | if (event.key === 'Backspace' || event.key === 'Delete') { 61 | handleValueRemoved(i); 62 | } 63 | }} 64 | onFocus={(event) => { 65 | event.preventDefault(); 66 | handleTagSelected(i) 67 | }} 68 | withRemoveButton 69 | onRemove={() => handleValueRemoved(i)} 70 | > 71 | {hasError && } 72 | {val} 73 | 74 | return hasError 75 | ? 80 | {inner} 81 | : inner 82 | }); 83 | 84 | const handleValueSubmit = (input: string) => { 85 | onValueAdded?.(input); 86 | } 87 | const handleValueRemoved = (index: number) => { 88 | onValueRemoved?.(index); 89 | } 90 | const handleTagSelected = (index: number) => { 91 | setSelectedIndex(index); 92 | onTagSelected?.(index); 93 | } 94 | 95 | useEffect(() => { 96 | const split = pillInputValue.split(delimiter); 97 | 98 | if (split.length > 1) { 99 | const last = split.pop(); 100 | split.forEach((value) => onValueAdded?.(value.trim())); 101 | setPillInputValue(last || ''); 102 | } 103 | }, [pillInputValue]) 104 | 105 | return ( 106 | 107 | 108 | 109 | {elements} 110 | 111 | 112 | { 117 | setSelectedIndex(-1); 118 | }} 119 | onChange={(event) => { 120 | setPillInputValue(event.currentTarget.value) 121 | setSelectedIndex(-1); 122 | }} 123 | onKeyDown={(event) => { 124 | if (event.key === 'Backspace' && (selectedIndex !== -1 || pillInputValue.length === 0)) { 125 | event.preventDefault(); 126 | handleValueRemoved(selectedIndex) 127 | } 128 | else if (event.key === 'Enter') { 129 | 130 | if (pillInputValue.length > 0) { 131 | handleValueSubmit(pillInputValue); 132 | setPillInputValue(''); 133 | } 134 | } 135 | }} 136 | /> 137 | 138 | 139 | 140 | 141 | ) 142 | } 143 | -------------------------------------------------------------------------------- /src/components/TraceConfiguration.tsx: -------------------------------------------------------------------------------- 1 | import { Anchor, Checkbox, Fieldset, Group, TagsInput, Text, TextInput, type CheckboxProps } from "@mantine/core"; 2 | import { IconChartDots3, IconAffiliate } from "@tabler/icons-react"; 3 | import React, { useCallback, useEffect, useRef } from "react"; 4 | import { useLocalStorage } from "~hooks/storage"; 5 | 6 | import { events as EventList } from "~utils/constants" 7 | import { defaultOptions } from "~utils/options"; 8 | 9 | const CheckboxIcon: CheckboxProps['icon'] = ({ ...others }) => 10 | ; 11 | 12 | type TraceConfigurationProps = { 13 | enabled: boolean 14 | } 15 | 16 | export default function TraceConfiguration({ enabled }: TraceConfigurationProps) { 17 | const [{ 18 | traceCollectorUrl, 19 | tracingEnabled, 20 | instrumentations, 21 | events, 22 | propagateTo 23 | }, setLocalStorage] = useLocalStorage(["traceCollectorUrl", "tracingEnabled", "instrumentations", "events", "propagateTo"]) 24 | 25 | const checkboxRef = useRef(null); 26 | 27 | const toggleDisabled = useCallback(() => { 28 | setLocalStorage({ tracingEnabled: !tracingEnabled }) 29 | }, [tracingEnabled]); 30 | 31 | // Hack for Firefox disabled fieldset checkbox event handling 32 | // see: https://stackoverflow.com/questions/63740106/checkbox-onchange-in-legend-inside-disabled-fieldset-not-firing-in-firefox-w 33 | useEffect(() => { 34 | checkboxRef.current?.addEventListener('change', toggleDisabled) 35 | 36 | return () => { 37 | checkboxRef.current?.removeEventListener('change', toggleDisabled) 38 | } 39 | }, [toggleDisabled]) 40 | 41 | return ( 42 |
50 | {tracingEnabled !== undefined && Tracing} 54 | ref={checkboxRef} 55 | disabled={false} 56 | onChange={() => { }} 57 | size="lg" 58 | variant='outline' 59 | styles={{ 60 | labelWrapper: { 61 | justifyContent: 'center' 62 | } 63 | }} 64 | aria-label='Enable or disable exporting traces' 65 | />} 66 | 67 | } 68 | disabled={!tracingEnabled}> 69 | 70 | {instrumentations !== undefined && setLocalStorage({ instrumentations: value as ("load" | "fetch" | "interaction")[] })} 74 | description={ 75 | <> 76 | Choose which events are automatically instrumented, see {" "} 77 | this README{" "}for details.}> 78 | 79 | 80 | 81 | 82 | 83 | } 84 | {traceCollectorUrl !== undefined && 88 | Choose where to send Protobuf-encoded OTLP traces over HTTP. 89 | 90 | } 91 | placeholder={defaultOptions.traceCollectorUrl} 92 | value={traceCollectorUrl} 93 | onChange={(event) => setLocalStorage({ traceCollectorUrl: event.currentTarget.value })} 94 | />} 95 | {events !== undefined && setLocalStorage({ events: value as (keyof HTMLElementEventMap)[] })} 98 | disabled={instrumentations?.indexOf('interaction') == -1 || !enabled} 99 | label="Event listeners" 100 | data={EventList} 101 | maxDropdownHeight={200} 102 | comboboxProps={{ position: 'bottom', middlewares: { flip: false, shift: false }, transitionProps: { transition: 'pop', duration: 200 } }} 103 | description={ 104 | <> 105 | Browser events to track, see{" "} 106 | 110 | HTMLElementEventMap 111 | {" "} 112 | for a list of valid events. 113 | 114 | } 115 | placeholder={events?.length == 0 ? defaultOptions.events.join(', ') : ''} 116 | splitChars={[","]} 117 | />} 118 | {propagateTo !== undefined && setLocalStorage({ propagateTo: value })} 121 | disabled={instrumentations?.indexOf('fetch') == -1 || !enabled} 122 | label="Forward trace context to" 123 | maxDropdownHeight={200} 124 | comboboxProps={{ position: 'bottom', middlewares: { flip: false, shift: false }, transitionProps: { transition: 'pop', duration: 200 } }} 125 | description={ 126 | <> 127 | Choose URLs (as regular expressions) which should receive W3C trace context on fetch/XHR. ⚠️ May cause request CORS failures. 128 | 129 | } 130 | placeholder={propagateTo?.length == 0 ? ".*" : ''} 131 | splitChars={[","]} 132 | />} 133 | 134 |
135 | ); 136 | } -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { deepmerge } from "deepmerge-ts"; 2 | import { consoleProxy } from "~utils/logging"; 3 | 4 | interface Config { 5 | version: string | undefined 6 | name: string | undefined 7 | } 8 | 9 | const configs = { 10 | default: { 11 | version: process.env.PLASMO_PUBLIC_PACKAGE_VERSION, 12 | name: process.env.PLASMO_PUBLIC_PACKAGE_NAME, 13 | }, 14 | production: {}, 15 | development: {} 16 | } 17 | 18 | const config: Config = deepmerge(configs.default, configs[process.env.NODE_ENV ?? 'development'][process.env.PLASMO_BROWSER ?? 'chrome']) 19 | 20 | export { 21 | config 22 | } -------------------------------------------------------------------------------- /src/content-script.ts: -------------------------------------------------------------------------------- 1 | import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; 2 | import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web'; 3 | import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; 4 | import { 5 | LoggerProvider, 6 | SimpleLogRecordProcessor 7 | } from '@opentelemetry/sdk-logs'; 8 | import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto'; 9 | import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; 10 | import { OTLPProtoExporterBrowserBase, getExportRequestProto } from '@opentelemetry/otlp-proto-exporter-base'; 11 | import { OTLPExporterError } from '@opentelemetry/otlp-exporter-base'; 12 | import { registerInstrumentations } from '@opentelemetry/instrumentation'; 13 | import { ZoneContextManager } from '@opentelemetry/context-zone'; 14 | import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, ATTR_TELEMETRY_SDK_LANGUAGE, ATTR_TELEMETRY_SDK_NAME, ATTR_TELEMETRY_SDK_VERSION, SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION, SEMRESATTRS_TELEMETRY_SDK_LANGUAGE, SEMRESATTRS_TELEMETRY_SDK_NAME, SEMRESATTRS_TELEMETRY_SDK_VERSION } from '@opentelemetry/semantic-conventions'; 15 | import { Resource } from '@opentelemetry/resources'; 16 | import { B3Propagator } from '@opentelemetry/propagator-b3'; 17 | import { CompositePropagator, W3CTraceContextPropagator } from '@opentelemetry/core'; 18 | 19 | import { MessageTypes, type CustomAppEvent } from '~types'; 20 | import { consoleProxy } from '~utils/logging'; 21 | import { wrapConsoleWithLoggerProvider } from '~telemetry/logs'; 22 | import { de } from '~utils/serde'; 23 | import type { ContentScriptConfigurationType } from '~storage/local/configuration'; 24 | import { config } from '~config'; 25 | 26 | function createSendOverride(sessionId: string, exporter: OTLPProtoExporterBrowserBase, type: MessageTypes) { 27 | 28 | return (objects: ExportItem[], onSuccess: () => void, onError: (error: OTLPExporterError) => void) => { 29 | 30 | if (objects.length === 0) { 31 | return onSuccess() 32 | } 33 | const serviceRequest = exporter.convert(objects) 34 | const clientType = exporter.getServiceClientType() 35 | const exportRequestType = getExportRequestProto(clientType) 36 | const otlp = exportRequestType.create(serviceRequest) 37 | 38 | if (otlp) { 39 | const bytes = exportRequestType.encode(otlp).finish(); 40 | // Because messages are JSON serialized and deserialized, we can't send a Uint8Array directly 41 | // So we send an array of numbers and convert it back to a Uint8Array on the other side 42 | const message = { bytes: Array.from(bytes), timeout: exporter.timeoutMillis, type } 43 | const key = `${sessionId}:relay-to-background` 44 | const event = new CustomEvent(key, { detail: message }) 45 | // Our `ISOLATED` content script will forward this event to the background script 46 | window.dispatchEvent(event) 47 | consoleProxy.debug(`message sent to relay using session id: ${sessionId}`, message) 48 | onSuccess() 49 | } else { 50 | onError(new OTLPExporterError('failed to create OTLP proto service request message')) 51 | } 52 | } 53 | } 54 | 55 | // TODO: investigate why reinstrumenting isn't working (or whether it can) 56 | const instrument = (sessionId: string, options: ContentScriptConfigurationType) => { 57 | 58 | if (!options || !options.enabled || !options.instrumentations || options.instrumentations.length === 0 || window.__OTEL_BROWSER_EXT_INSTRUMENTATION__) { 59 | consoleProxy.debug(`not instrumenting as either options missing or already instrumented`, options, window.__OTEL_BROWSER_EXT_INSTRUMENTATION__) 60 | return () => { } 61 | } 62 | window.__OTEL_BROWSER_EXT_INSTRUMENTATION__ = () => { } 63 | consoleProxy.debug(`instrumenting`, { sessionId, options }) 64 | 65 | const resource = new Resource({ 66 | [ATTR_SERVICE_NAME]: config.name, 67 | [ATTR_SERVICE_VERSION]: config.version, // TODO: probably want to inject this 68 | [ATTR_TELEMETRY_SDK_LANGUAGE]: 'webjs', 69 | [ATTR_TELEMETRY_SDK_NAME]: 'opentelemetry', 70 | [ATTR_TELEMETRY_SDK_VERSION]: '1.22.0', // TODO: replace with resolved version 71 | 'browser.name': process.env.PLASMO_BROWSER, // TODO: fix why this is undefined 72 | 'extension.session.id': sessionId, 73 | ...Object.fromEntries(options.attributes.entries()) 74 | }) 75 | 76 | let tracerProvider: WebTracerProvider | undefined 77 | 78 | if (options.tracingEnabled) { 79 | tracerProvider = new WebTracerProvider({ 80 | resource, 81 | }) 82 | const traceExporter = new OTLPTraceExporter({ 83 | concurrencyLimit: options.concurrencyLimit, 84 | }) 85 | // @ts-ignore 86 | traceExporter.send = createSendOverride(sessionId, traceExporter, MessageTypes.OTLPTraceMessage) 87 | // TODO: make batching configurable, choosing simple for now to avoid losing data on page navigations 88 | const traceProcessor = new SimpleSpanProcessor(traceExporter); 89 | tracerProvider.addSpanProcessor(traceProcessor); 90 | tracerProvider.register({ 91 | contextManager: new ZoneContextManager(), 92 | propagator: new CompositePropagator({ 93 | propagators: options.propagateTo.length > 0 ? [ 94 | new B3Propagator(), 95 | new W3CTraceContextPropagator(), 96 | ] : [], 97 | }), 98 | }) 99 | } 100 | 101 | let loggerProvider: LoggerProvider | undefined 102 | 103 | if (options.loggingEnabled) { 104 | const logExporter = new OTLPLogExporter({ 105 | concurrencyLimit: options.concurrencyLimit, 106 | }) 107 | // @ts-ignore 108 | logExporter.send = createSendOverride(sessionId, logExporter, MessageTypes.OTLPLogMessage) 109 | loggerProvider = new LoggerProvider({ 110 | resource 111 | }) 112 | // TODO: make batching configurable, choosing simple for now to avoid losing data on page navigations 113 | loggerProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter)) 114 | wrapConsoleWithLoggerProvider(loggerProvider) 115 | } 116 | const propagateTraceHeaderCorsUrls = options.propagateTo.map((url) => new RegExp(url)) 117 | const clearTimingResources = true 118 | const instrumentations = { 119 | load: [ 120 | ['@opentelemetry/instrumentation-document-load', {}] 121 | ], 122 | fetch: [ 123 | ['@opentelemetry/instrumentation-xml-http-request', { 124 | clearTimingResources, 125 | propagateTraceHeaderCorsUrls, 126 | // TODO: implement so we can abandon the related instrumentation patches 127 | // applyCustomAttributesOnSpan: async (span: Span, xhr: XMLHttpRequest) => { 128 | // if (xhr.status >= 400) { 129 | // span.setStatus({ code: 2, message: xhr.statusText }) 130 | // } 131 | // } 132 | }], 133 | ['@opentelemetry/instrumentation-fetch', { 134 | clearTimingResources, 135 | propagateTraceHeaderCorsUrls, 136 | // TODO: implement so we can abandon the related instrumentation patches 137 | // applyCustomAttributesOnSpan: async (span: Span, request: Request | RequestInit, response: Response | FetchError) => { 138 | // if ('message' in response) { 139 | // span.setStatus({ code: 2, message: response.message }) 140 | // } 141 | // }, 142 | }] 143 | ], 144 | interaction: [ 145 | ['@opentelemetry/instrumentation-user-interaction', { 146 | eventNames: options.events, 147 | }] 148 | ], 149 | } 150 | const instrumentationsToRegister = {} 151 | options.instrumentations.forEach((instrumentation: string) => { 152 | instrumentations[instrumentation].forEach((setting) => { 153 | instrumentationsToRegister[setting[0]] = setting[1] 154 | }) 155 | }) 156 | consoleProxy.debug(`registering instrumentations`, instrumentationsToRegister) 157 | const deregister = registerInstrumentations({ 158 | instrumentations: [ 159 | getWebAutoInstrumentations(instrumentationsToRegister), 160 | ], 161 | tracerProvider, 162 | loggerProvider, 163 | }); 164 | 165 | return () => { 166 | deregister() 167 | window.__OTEL_BROWSER_EXT_INSTRUMENTATION__ = undefined 168 | } 169 | } 170 | 171 | export type InjectContentScriptArgs = { 172 | sessionId: string, 173 | options: ContentScriptConfigurationType | string, 174 | retries?: number, 175 | backoff?: number, 176 | } 177 | 178 | export default function injectContentScript({ sessionId, options, retries = 10, backoff = 10 }: InjectContentScriptArgs) { 179 | if (retries <= 0) { 180 | return 181 | } 182 | 183 | try { 184 | if (typeof options === 'string') { 185 | options = de(options) 186 | } 187 | window.__OTEL_BROWSER_EXT_INSTRUMENTATION__ = instrument(sessionId, options); 188 | 189 | const key = `${sessionId}:relay-from-background` 190 | const listener = (event: CustomAppEvent) => { 191 | try { 192 | if (event.detail.type === MessageTypes.Disconnect) { 193 | consoleProxy.debug(`received disconnect message from relay`, event.detail) 194 | window.__OTEL_BROWSER_EXT_INSTRUMENTATION__ && window.__OTEL_BROWSER_EXT_INSTRUMENTATION__() 195 | window.removeEventListener(key, listener) 196 | } else if (event.detail.type === MessageTypes.ConfigurationChanged) { 197 | consoleProxy.debug(`received storage changed message from relay`, event.detail) 198 | options = { 199 | ...options as ContentScriptConfigurationType, 200 | ...(event.detail.data || {}) 201 | } 202 | consoleProxy.debug(`re-instrumenting with parsed options`, options) 203 | window.__OTEL_BROWSER_EXT_INSTRUMENTATION__ && window.__OTEL_BROWSER_EXT_INSTRUMENTATION__() 204 | window.__OTEL_BROWSER_EXT_INSTRUMENTATION__ = instrument(sessionId, options) 205 | } else { 206 | consoleProxy.debug(`received malformed message from relay`, event.detail) 207 | } 208 | } catch (e) { 209 | consoleProxy.error(`error handling message from relay`, e, event) 210 | } 211 | } 212 | // listen for messages from the relay 213 | window.addEventListener(key, listener) 214 | } catch (e) { 215 | consoleProxy.error(`error injecting content script`, e, options) 216 | setTimeout(() => { 217 | consoleProxy.debug(`attempting to reconnect in ${backoff} ms`) 218 | injectContentScript({ sessionId, options, retries: retries - 1, backoff: backoff * 2 }) 219 | }, backoff) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/generated/schemas/configuration.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "Configuration", 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "type": "object", 5 | "properties": { 6 | "enabled": { 7 | "type": "boolean", 8 | "description": "Whether the extension is enabled." 9 | }, 10 | "matchPatterns": { 11 | "type": "array", 12 | "items": { 13 | "type": "string" 14 | }, 15 | "examples": [ 16 | [ 17 | "http://localhost/*", 18 | "https://*.example.com/*" 19 | ] 20 | ], 21 | "description": "List of [match patterns](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns) for which the extension should be enabled." 22 | }, 23 | "attributes": { 24 | "type": "object", 25 | "additionalProperties": { 26 | "type": "string" 27 | }, 28 | "examples": [ 29 | { 30 | "key": "value" 31 | } 32 | ], 33 | "description": "[Attributes](https://opentelemetry.io/docs/specs/semconv/general/attributes/) to be added to all traces." 34 | }, 35 | "headers": { 36 | "type": "object", 37 | "additionalProperties": { 38 | "type": "string" 39 | }, 40 | "examples": [ 41 | { 42 | "x-example-header": "value" 43 | } 44 | ], 45 | "description": "HTTP headers to be added to requests when exporting collected telemetry." 46 | }, 47 | "propagateTo": { 48 | "type": "array", 49 | "items": { 50 | "type": "string" 51 | }, 52 | "examples": [ 53 | [ 54 | "https://example.com/.*" 55 | ] 56 | ], 57 | "description": "List of regular expressions to match against outbound request URLs for which trace context should be forwarded." 58 | }, 59 | "concurrencyLimit": { 60 | "type": "number", 61 | "examples": [ 62 | 50 63 | ], 64 | "description": "Maximum number of concurrent requests that can be queued for export." 65 | }, 66 | "tracing": { 67 | "$ref": "#/definitions/UserFacingTracingConfigurationType" 68 | }, 69 | "logging": { 70 | "$ref": "#/definitions/UserFacingLoggingConfigurationType" 71 | } 72 | }, 73 | "required": [ 74 | "enabled", 75 | "matchPatterns", 76 | "attributes", 77 | "headers", 78 | "propagateTo", 79 | "concurrencyLimit", 80 | "tracing", 81 | "logging" 82 | ], 83 | "additionalProperties": false, 84 | "title": "Configuration", 85 | "description": "User-facing settings for the extension", 86 | "definitions": { 87 | "UserFacingTracingConfigurationType": { 88 | "type": "object", 89 | "properties": { 90 | "enabled": { 91 | "type": "boolean", 92 | "description": "Whether tracing is enabled." 93 | }, 94 | "collectorUrl": { 95 | "type": "string", 96 | "examples": [ 97 | "http://localhost:4318/v1/traces" 98 | ], 99 | "description": "URL to which traces should be exported. Must accept Protobuf-encoded OTLP traces over HTTP." 100 | }, 101 | "events": { 102 | "type": "array", 103 | "items": { 104 | "type": "string", 105 | "enum": [ 106 | "fullscreenchange", 107 | "fullscreenerror", 108 | "abort", 109 | "animationcancel", 110 | "animationend", 111 | "animationiteration", 112 | "animationstart", 113 | "auxclick", 114 | "beforeinput", 115 | "beforetoggle", 116 | "blur", 117 | "cancel", 118 | "canplay", 119 | "canplaythrough", 120 | "change", 121 | "click", 122 | "close", 123 | "compositionend", 124 | "compositionstart", 125 | "compositionupdate", 126 | "contextlost", 127 | "contextmenu", 128 | "contextrestored", 129 | "copy", 130 | "cuechange", 131 | "cut", 132 | "dblclick", 133 | "drag", 134 | "dragend", 135 | "dragenter", 136 | "dragleave", 137 | "dragover", 138 | "dragstart", 139 | "drop", 140 | "durationchange", 141 | "emptied", 142 | "ended", 143 | "error", 144 | "focus", 145 | "focusin", 146 | "focusout", 147 | "formdata", 148 | "gotpointercapture", 149 | "input", 150 | "invalid", 151 | "keydown", 152 | "keypress", 153 | "keyup", 154 | "load", 155 | "loadeddata", 156 | "loadedmetadata", 157 | "loadstart", 158 | "lostpointercapture", 159 | "mousedown", 160 | "mouseenter", 161 | "mouseleave", 162 | "mousemove", 163 | "mouseout", 164 | "mouseover", 165 | "mouseup", 166 | "paste", 167 | "pause", 168 | "play", 169 | "playing", 170 | "pointercancel", 171 | "pointerdown", 172 | "pointerenter", 173 | "pointerleave", 174 | "pointermove", 175 | "pointerout", 176 | "pointerover", 177 | "pointerup", 178 | "progress", 179 | "ratechange", 180 | "reset", 181 | "resize", 182 | "scroll", 183 | "scrollend", 184 | "securitypolicyviolation", 185 | "seeked", 186 | "seeking", 187 | "select", 188 | "selectionchange", 189 | "selectstart", 190 | "slotchange", 191 | "stalled", 192 | "submit", 193 | "suspend", 194 | "timeupdate", 195 | "toggle", 196 | "touchcancel", 197 | "touchend", 198 | "touchmove", 199 | "touchstart", 200 | "transitioncancel", 201 | "transitionend", 202 | "transitionrun", 203 | "transitionstart", 204 | "volumechange", 205 | "waiting", 206 | "webkitanimationend", 207 | "webkitanimationiteration", 208 | "webkitanimationstart", 209 | "webkittransitionend", 210 | "wheel" 211 | ] 212 | }, 213 | "examples": [ 214 | [ 215 | "submit", 216 | "click", 217 | "keypress" 218 | ] 219 | ], 220 | "description": "List of [browser events](https://azuresdkdocs.blob.core.windows.net/$web/javascript/azure-app-configuration/1.1.0/interfaces/htmlelementeventmap.html) to track (if 'interaction' instrumentation is enabled)." 221 | }, 222 | "instrumentations": { 223 | "type": "array", 224 | "items": { 225 | "type": "string", 226 | "enum": [ 227 | "load", 228 | "fetch", 229 | "interaction" 230 | ] 231 | }, 232 | "description": "List of automatic instrumentations to enable." 233 | } 234 | }, 235 | "required": [ 236 | "enabled", 237 | "collectorUrl", 238 | "events", 239 | "instrumentations" 240 | ], 241 | "additionalProperties": false, 242 | "description": "Configuration for trace telemetry." 243 | }, 244 | "UserFacingLoggingConfigurationType": { 245 | "type": "object", 246 | "properties": { 247 | "enabled": { 248 | "type": "boolean", 249 | "description": "Whether logging is enabled." 250 | }, 251 | "collectorUrl": { 252 | "type": "string", 253 | "examples": [ 254 | "http://localhost:4318/v1/logs" 255 | ], 256 | "description": "URL to which logs should be exported. Must accept Protobuf-encoded OTLP logs over HTTP." 257 | } 258 | }, 259 | "required": [ 260 | "enabled", 261 | "collectorUrl" 262 | ], 263 | "additionalProperties": false, 264 | "description": "Configuration for logging telemetry." 265 | } 266 | } 267 | } -------------------------------------------------------------------------------- /src/hooks/platform.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { getPlatformInfo } from "~utils/platform"; 3 | 4 | export const usePlatformInfo = () => { 5 | const [platformInfo, setPlatformInfo] = useState(null); 6 | useEffect(() => { 7 | const getInfo = async () => { 8 | const info = await getPlatformInfo(); 9 | setPlatformInfo(info); 10 | } 11 | getInfo(); 12 | }, []); 13 | return platformInfo; 14 | } -------------------------------------------------------------------------------- /src/hooks/storage.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react" 2 | import { de } from "~utils/serde"; 3 | import { defaultLocalStorage, getStorage, LocalStorage, setStorage, type ExtractLocalStorageKeys as ExtractKeysFromLocalStorage, type LocalStorageType } from "~storage/local"; 4 | import { consoleProxy } from "~utils/logging"; 5 | import { pick } from "~utils/generics"; 6 | import { shallowEqual } from "@mantine/hooks"; 7 | 8 | export type ProxyListeners = { 9 | [K in chrome.storage.AreaName]: ((data: StorageCache[K]) => void)[]; 10 | }; 11 | 12 | export type StorageCache = { 13 | local: LocalStorageType 14 | sync?: any, 15 | session?: any, 16 | managed?: any, 17 | } 18 | 19 | const cache: StorageCache = { 20 | local: {} as LocalStorageType, 21 | } 22 | 23 | const proxyListeners: ProxyListeners = { 24 | local: [], 25 | sync: [], 26 | session: [], 27 | managed: [], 28 | } 29 | 30 | const storageListener = (event: Record, area: chrome.storage.AreaName) => { 31 | 32 | const updates = {} 33 | 34 | Object.entries(event).forEach(([key, { newValue }]) => { 35 | cache[area][key] = de(newValue); 36 | updates[key] = cache[area][key]; 37 | }) 38 | 39 | consoleProxy.debug('storage changed in storageListener, cache after:', cache[area]) 40 | 41 | proxyListeners[area].forEach((listener) => { 42 | listener(updates as any); 43 | }) 44 | } 45 | 46 | chrome.storage.onChanged.addListener(storageListener); 47 | 48 | export type UseLocalStorageType = [Partial>, (updates: Partial>) => Promise] 49 | 50 | export function useLocalStorage(keys?: T): UseLocalStorageType { 51 | if (!keys) { 52 | return useStorage(defaultLocalStorage, 'local') as UseLocalStorageType 53 | } 54 | const obj = keys.reduce((acc, key) => { 55 | if (key in defaultLocalStorage) { 56 | acc[key] = defaultLocalStorage[key]; 57 | } 58 | return acc; 59 | }, {} as Partial>); 60 | return useStorage(obj, 'local') as UseLocalStorageType 61 | } 62 | 63 | const getUpdates = (oldState: T, newState: Partial): Partial => { 64 | const updates = Object.entries(newState).reduce((acc, [key, value]) => { 65 | if (oldState[key] !== value) { 66 | acc[key] = value; 67 | } 68 | return acc; 69 | }, {} as Partial); 70 | return updates; 71 | } 72 | 73 | export function useStorage(keysWithDefaults: T, storageArea: chrome.storage.AreaName = 'local'): [Partial, (updates: Partial) => Promise] { 74 | const chooseKeys = keysWithDefaults ? Object.keys(keysWithDefaults) as (keyof T)[] : []; 75 | const [state, setState] = useState(pick(cache[storageArea], chooseKeys) as Partial); 76 | 77 | const listener = useCallback((newState: T) => { 78 | const intersection = pick(newState, chooseKeys as (keyof T)[]); 79 | 80 | setState((prevState) => { 81 | if (!shallowEqual(prevState, intersection)) { 82 | return { ...prevState, ...intersection }; 83 | } 84 | return prevState; // No change 85 | }); 86 | }, [chooseKeys, setState]); 87 | 88 | useEffect(() => { 89 | if (chooseKeys.length == 0) return; 90 | consoleProxy.debug('looking for', chooseKeys, `in cache['${storageArea}']:`, cache[storageArea]); 91 | // if we can answer the request from cache, do so 92 | if (chooseKeys.every((key) => key in cache[storageArea])) { 93 | consoleProxy.debug('cache hit for', chooseKeys, `in cache['${storageArea}']:`, cache[storageArea]); 94 | return; 95 | } 96 | 97 | getStorage(storageArea, {...keysWithDefaults, ...cache[storageArea]}).then((response) => { 98 | cache[storageArea] = { ...cache[storageArea], ...response }; 99 | consoleProxy.debug('setting state from cache:', pick(cache[storageArea], chooseKeys)) 100 | setState(pick(cache[storageArea], chooseKeys)); 101 | }); 102 | }, [chooseKeys, storageArea, keysWithDefaults]) 103 | 104 | useEffect(() => { 105 | proxyListeners[storageArea].push(listener); 106 | 107 | return () => { 108 | const index = proxyListeners[storageArea].indexOf(listener); 109 | if (index !== -1) { 110 | proxyListeners[storageArea].splice(index, 1); 111 | } 112 | } 113 | }, [keysWithDefaults]) 114 | 115 | const setStateProxy = async (state: Partial) => { 116 | const updates = getUpdates(cache[storageArea], state); 117 | 118 | if (Object.keys(updates).length > 0) { 119 | setState((prevState) => ({ ...prevState, ...updates })) 120 | 121 | consoleProxy.debug('setting local storage state with updates:', updates, 'from state:', state, 'previously cached', cache[storageArea]) 122 | await setStorage(storageArea, updates); 123 | } 124 | } 125 | 126 | return [state, setStateProxy]; 127 | } 128 | -------------------------------------------------------------------------------- /src/listeners/index.ts: -------------------------------------------------------------------------------- 1 | import '~listeners/storage' 2 | import '~listeners/runtime' 3 | import '~listeners/tabs' 4 | import '~listeners/permissions' -------------------------------------------------------------------------------- /src/listeners/permissions/index.ts: -------------------------------------------------------------------------------- 1 | import { getLocalStorage, setLocalStorage } from "~storage/local" 2 | import { validatePatternPermissions } from "~utils/match-pattern" 3 | 4 | const checkPatternPermissions = async () => { 5 | const { matchPatterns } = await getLocalStorage(['matchPatterns']) 6 | const matchPatternErrors = await validatePatternPermissions(matchPatterns) 7 | setLocalStorage({ matchPatternErrors }) 8 | } 9 | 10 | chrome.permissions.onAdded.addListener(checkPatternPermissions) 11 | chrome.permissions.onRemoved.addListener(checkPatternPermissions) -------------------------------------------------------------------------------- /src/listeners/runtime/index.ts: -------------------------------------------------------------------------------- 1 | import '~listeners/runtime/onConnect'; -------------------------------------------------------------------------------- /src/listeners/runtime/onConnect/index.ts: -------------------------------------------------------------------------------- 1 | import { consoleProxy } from '~utils/logging' 2 | import { MessageTypes, type OTLPExportTraceMessage, type OTLPExportLogMessage, type ToBackgroundMessage, type TypedPort, type ToContentScriptMessage } from '~types' 3 | import { match } from '~utils/match-pattern' 4 | import { getLocalStorage } from '~storage/local' 5 | import { addPort, removePort } from '~utils/background-ports' 6 | 7 | const getDestinationForMessage = async (message: ToBackgroundMessage) => { 8 | switch (message.type) { 9 | case MessageTypes.OTLPLogMessage: 10 | return (await getLocalStorage(['logCollectorUrl'])).logCollectorUrl 11 | case MessageTypes.OTLPTraceMessage: 12 | return (await getLocalStorage(['traceCollectorUrl'])).traceCollectorUrl 13 | case MessageTypes.OTLPMetricMessage: 14 | // return (await getLocalStorage(['metricCollectorUrl'])).metricCollectorUrl 15 | default: 16 | throw new Error('unknown message type') 17 | } 18 | } 19 | 20 | chrome.runtime.onConnect.addListener(async (p: TypedPort) => { 21 | 22 | consoleProxy.debug('connection attempt on port:', p) 23 | 24 | const { matchPatterns } = await getLocalStorage(['matchPatterns']) 25 | 26 | if (!match(p.sender.url, matchPatterns)) { 27 | consoleProxy.debug('no pattern match, ignoring connection attempt', p.sender.url, matchPatterns) 28 | return 29 | } 30 | 31 | addPort(p) 32 | 33 | consoleProxy.debug('pattern match', p.sender.url, matchPatterns) 34 | p.onMessage.addListener(async (message) => { 35 | consoleProxy.debug('received message', message) 36 | 37 | switch (message.type) { 38 | case MessageTypes.OTLPLogMessage: 39 | case MessageTypes.OTLPTraceMessage: 40 | // Timeout currently ignored 41 | const { bytes } = MessageTypes.OTLPLogMessage ? message as OTLPExportLogMessage : message as OTLPExportTraceMessage 42 | 43 | // Even though the content script could send us the headers and url, we don't trust them 44 | // So in the absolute worst case adversarial scenario we're still just sending arbitrary bytes to our chosen server 45 | const { headers: stored } = await getLocalStorage(['headers']) 46 | // TODO: stop sending attributes to content script, set here instead 47 | consoleProxy.debug('stored headers', stored) 48 | const headers = { 49 | ...(Object.fromEntries(stored.entries())), 50 | 'Content-Type': 'application/x-protobuf', 51 | Accept: 'application/x-protobuf' 52 | } 53 | let url = await getDestinationForMessage(message) 54 | consoleProxy.debug('message destination', url) 55 | const body = new Blob([new Uint8Array(bytes)], { type: 'application/x-protobuf' }); 56 | 57 | try { 58 | consoleProxy.debug('sending message', { url, headers }) 59 | // TODO: retries and timeouts 60 | await fetch(url, { 61 | method: 'POST', 62 | headers, 63 | body, 64 | }) 65 | } catch (e) { 66 | consoleProxy.error('error sending message', e) 67 | } 68 | break; 69 | default: 70 | consoleProxy.error('unhandled message type', message) 71 | } 72 | }); 73 | 74 | p.onDisconnect.addListener((p) => { 75 | consoleProxy.debug('port disconnected', p) 76 | removePort(p) 77 | }) 78 | }); -------------------------------------------------------------------------------- /src/listeners/storage/index.ts: -------------------------------------------------------------------------------- 1 | import '~listeners/storage/onChanged' -------------------------------------------------------------------------------- /src/listeners/storage/onChanged/configTextSync.ts: -------------------------------------------------------------------------------- 1 | import { de, ser } from "~utils/serde"; 2 | import { getStorage, removeLocalStorage, setLocalStorage } from "~storage/local"; 3 | import { LocalStorage } from "~storage/local"; 4 | import { Configuration, defaultConfiguration, UserFacingConfiguration } from "~storage/local/configuration"; 5 | import { consoleProxy } from "~utils/logging"; 6 | import { pick } from "~utils/generics"; 7 | 8 | /** 9 | * Syncs changes between configText and config storage. 10 | */ 11 | chrome.storage.onChanged.addListener(async (event: Record, area) => { 12 | const { configText, editorState, ...other } = pick(event, ['configText', 'editorState', ...(Object.keys(defaultConfiguration) as (keyof Configuration)[])]) 13 | 14 | // Serialize config text as storage, persist changes 15 | if (event.configText) { 16 | try { 17 | 18 | if (configText.newValue === configText.oldValue) { 19 | consoleProxy.debug('config text same, skipping') 20 | return 21 | } 22 | const config = de(de(configText.newValue), UserFacingConfiguration) 23 | consoleProxy.debug('deserialized config', config) 24 | await setLocalStorage(config.serializable()) 25 | } catch (e) { 26 | consoleProxy.error('failed to deserialize config', e) 27 | } 28 | } 29 | // Technically we could translate these changes into EditorState transactions 30 | // which would allow cool things like undo/redo, but that seems like unnecessary extra work for now 31 | else if (Object.keys(other).length > 0) { 32 | // Otherwise, serialize config as config text, persist changes, *and* clear editor state 33 | // (if this change invalidates the editor state) 34 | // Should only be ran when user configuration inputs change 35 | // assumes that an immediate read will contain the latest config (TODO: verify this assumption) 36 | const { configText, ...stored } = await getStorage('local', { ...(new Configuration()), configText: null }) 37 | const from = UserFacingConfiguration.from(stored) 38 | consoleProxy.debug('user facing config to be serialized', from) 39 | const serialized = ser(from, true) 40 | consoleProxy.debug('serialized configText', serialized) 41 | 42 | // TODO: technically whitespace changes should not trigger this, but it's fine for now 43 | if (serialized !== configText) { 44 | consoleProxy.debug('configText changed, updating', configText, serialized) 45 | await setLocalStorage({ configText: serialized, editorState: null }) 46 | } 47 | } 48 | }) -------------------------------------------------------------------------------- /src/listeners/storage/onChanged/forwardChanges.ts: -------------------------------------------------------------------------------- 1 | import type { LocalStorage, LocalStorageType } from "~storage/local" 2 | import { consoleProxy } from "~utils/logging" 3 | import { getPorts } from "~utils/background-ports" 4 | import { MessageTypes, type ToRelayMessage } from "~types" 5 | 6 | /** 7 | * Forwards storage changes to all content scripts. 8 | */ 9 | chrome.storage.onChanged.addListener((event: Record, area) => { 10 | 11 | // TODO: change this and related if we ever use storage in other areas 12 | if (area !== 'local') return 13 | 14 | consoleProxy.debug('storage changed', { event }) 15 | 16 | const parsed = Object.entries(event).reduce((acc, [k, v]) => { 17 | return { ...acc, [k]: v.newValue } 18 | }, {} as Partial) 19 | 20 | const message = { 21 | type: MessageTypes.StorageChanged, 22 | data: parsed 23 | } as ToRelayMessage 24 | consoleProxy.debug('storage changed message to send', message) 25 | 26 | const ports = getPorts() 27 | 28 | Object.keys(ports).forEach((k) => { 29 | ports[k].postMessage(message); 30 | }) 31 | }) -------------------------------------------------------------------------------- /src/listeners/storage/onChanged/index.ts: -------------------------------------------------------------------------------- 1 | import '~listeners/storage/onChanged/configTextSync' 2 | import '~listeners/storage/onChanged/matchPatternsSync' 3 | import '~listeners/storage/onChanged/forwardChanges' -------------------------------------------------------------------------------- /src/listeners/storage/onChanged/matchPatternsSync.ts: -------------------------------------------------------------------------------- 1 | import { setLocalStorage, type LocalStorage } from "~storage/local"; 2 | import { consoleProxy } from "~utils/logging"; 3 | import { validatePatternPermissions, validatePatterns } from "~utils/match-pattern"; 4 | import { de } from "~utils/serde"; 5 | 6 | /** 7 | * Updates matchPatternErrors when matchPatterns are changed. Permission syncing unfortunately needs to occur in the UI 8 | * due to permissions requests requiring a user interaction in order to be initiated, but we can keep this portion separate. 9 | */ 10 | chrome.storage.onChanged.addListener(async (event: Record, area) => { 11 | const { matchPatterns } = event 12 | 13 | if (matchPatterns) { 14 | consoleProxy.debug('matchPatterns changed', { matchPatterns }) 15 | const deseralized = de(matchPatterns.newValue) // TODO: should be possible to infer type of .newValue 16 | 17 | let [validPatterns, patternErrors] = validatePatterns(deseralized) 18 | patternErrors = patternErrors.concat(await validatePatternPermissions(validPatterns)) 19 | 20 | consoleProxy.debug('matchPatterns validation results', { validPatterns, patternErrors }) 21 | setLocalStorage({ matchPatternErrors: patternErrors }) 22 | } 23 | }) -------------------------------------------------------------------------------- /src/listeners/tabs/index.ts: -------------------------------------------------------------------------------- 1 | import '~listeners/tabs/onUpdated' -------------------------------------------------------------------------------- /src/listeners/tabs/onUpdated/index.ts: -------------------------------------------------------------------------------- 1 | import { consoleProxy } from '~utils/logging' 2 | import injectContentScript from 'inlinefunc:content-script' 3 | import injectRelay from 'inlinefunc:message-relay' 4 | import { getOptions } from '~utils/options' 5 | import { match } from '~utils/match-pattern' 6 | import { ser } from '~utils/serde' 7 | import { uuidv7 } from 'uuidv7'; 8 | import { getLocalStorage } from '~storage/local' 9 | 10 | chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { 11 | if ( 12 | changeInfo.status === "complete") { 13 | 14 | // get user-specified match patterns or defaults 15 | const { matchPatterns } = await getLocalStorage(['matchPatterns']) 16 | // check whether current URL matches any patterns 17 | const matches = match(tab.url, matchPatterns) 18 | 19 | if (!matches) { 20 | consoleProxy.debug("no pattern match in onupdated listener, not injecting content script", tab.url, matchPatterns) 21 | return 22 | } 23 | 24 | consoleProxy.debug("injecting content script") 25 | const options = await getOptions() 26 | 27 | consoleProxy.debug("loaded options", options) 28 | const sessionId = uuidv7() 29 | 30 | await chrome.scripting.executeScript({ 31 | target: { tabId, allFrames: true }, 32 | func: injectRelay, 33 | args: [{ 34 | sessionId 35 | }], 36 | injectImmediately: true, 37 | world: "ISOLATED" 38 | }) 39 | 40 | await chrome.scripting.executeScript({ 41 | target: { tabId, allFrames: true }, 42 | func: injectContentScript, 43 | args: [{ 44 | sessionId, 45 | options: ser(options), 46 | }], 47 | injectImmediately: true, 48 | world: "MAIN" 49 | }) 50 | } 51 | }) -------------------------------------------------------------------------------- /src/message-relay.ts: -------------------------------------------------------------------------------- 1 | import { parseStorageResponse } from "~storage/local"; 2 | import { defaultContentScriptConfiguration } from "~storage/local/configuration/content-script"; 3 | import { MessageTypes, type CustomAppEvent, type ToContentScriptMessage, type ToBackgroundMessage, type TypedPort, type ToRelayMessage, type ConfigurationChangedMessage } from "~types"; 4 | import { pick } from "~utils/generics"; 5 | import { consoleProxy } from "~utils/logging"; 6 | 7 | export type InjectRelayArgs = { 8 | sessionId: string 9 | } 10 | 11 | export default function injectRelay({ sessionId }: InjectRelayArgs) { 12 | consoleProxy.debug(`injecting relay for session ${sessionId}`) 13 | const port: TypedPort = chrome.runtime.connect(); 14 | consoleProxy.debug(`port`, port) 15 | const fromBackground = `${sessionId}:relay-from-background` 16 | const toBackground = `${sessionId}:relay-to-background` 17 | 18 | // Relay messages/events from the content script to the background script 19 | window.addEventListener(toBackground, (event: CustomEvent) => { 20 | consoleProxy.debug(`received message to relay to background script`, event.detail); 21 | try { 22 | port.postMessage(event.detail) 23 | } catch (e) { 24 | consoleProxy.debug(`error sending message`, e) 25 | } 26 | }) 27 | 28 | // Relay messages/events from the background script to the content script 29 | port.onDisconnect.addListener(obj => { 30 | consoleProxy.debug(`background script disconnected port`, obj); 31 | port.disconnect() 32 | const event: CustomAppEvent = new CustomEvent(fromBackground, { 33 | detail: { type: MessageTypes.Disconnect } as ToContentScriptMessage 34 | }) 35 | window.dispatchEvent(event) 36 | }) 37 | 38 | port.onMessage.addListener((message) => { 39 | consoleProxy.debug(`received message to relay from background script`, message); 40 | 41 | switch (message.type) { 42 | case MessageTypes.StorageChanged: 43 | const relay = { 44 | type: MessageTypes.ConfigurationChanged, 45 | // extract only the keys content script needs to run (limiting data exposure) 46 | data: pick(parseStorageResponse(message.data), Object.keys(defaultContentScriptConfiguration)) 47 | } as ConfigurationChangedMessage 48 | consoleProxy.debug(`deserialized message to be relayed`, relay); 49 | // no keys we care about 50 | if (Object.keys(relay.data).length === 0) { 51 | consoleProxy.debug(`no relevant changes found to forward to content script, skipping`); 52 | return 53 | } 54 | const event: CustomAppEvent = new CustomEvent(fromBackground, { 55 | detail: relay 56 | }) 57 | window.dispatchEvent(event) 58 | break 59 | default: 60 | consoleProxy.debug(`malformed message`, message) 61 | } 62 | }) 63 | } -------------------------------------------------------------------------------- /src/popup.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: auto; 4 | background-color: #171b38; 5 | } 6 | 7 | .popup-container { 8 | min-width: 64ch; 9 | flex-direction: column; 10 | } 11 | 12 | .popup-logo { 13 | max-width: 6rem; 14 | } 15 | 16 | .mantine-Pill-root:where([data-disabled], :has(button:disabled)) { 17 | padding-inline-end: 0 !important; 18 | } 19 | 20 | .mantine-Pill-root:where([data-disabled], :has(button:disabled))>button.mantine-CloseButton-root { 21 | min-width: 2em !important; 22 | padding-inline: .1em .3em !important; 23 | display: inline-flex !important; 24 | } 25 | 26 | .mantine-Pill-root:where([data-disabled], :has(button:disabled))>button.mantine-CloseButton-root>svg { 27 | display: block !important; 28 | } -------------------------------------------------------------------------------- /src/popup.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createTheme, 3 | Flex, 4 | MantineProvider, 5 | } from "@mantine/core" 6 | 7 | 8 | import "@mantine/core/styles.css" 9 | import "./popup.css" 10 | import LinkSection from "~components/LinkSection" 11 | import Configuration from "~components/Configuration" 12 | 13 | const theme = createTheme({ 14 | /** Put your mantine theme override here */ 15 | cursorType: 'pointer', 16 | components: { 17 | Fieldset: { 18 | styles: { 19 | legend: { 20 | paddingRight: '1rem' 21 | } 22 | } 23 | }, 24 | TagsInput: { 25 | styles: {} 26 | }, 27 | } 28 | }) 29 | 30 | export default function IndexPopup() { 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/scripts/generate-jsonschema/main.ts: -------------------------------------------------------------------------------- 1 | import { createProgram, createParser, SchemaGenerator, createFormatter, DEFAULT_CONFIG } from "ts-json-schema-generator" 2 | import fs from 'fs'; 3 | 4 | const config = { 5 | ...DEFAULT_CONFIG, 6 | path: 'src/storage/local/configuration/index.ts', 7 | tsconfig: 'tsconfig.json', 8 | type: 'UserFacingConfigurationType', 9 | topRef: false, 10 | schemaId: 'Configuration', 11 | jsDoc: 'extended' as const, 12 | } 13 | const program = createProgram(config); 14 | const parser = createParser(program, config); 15 | const formatter = createFormatter(config); 16 | const generator = new SchemaGenerator(program, parser, formatter, config); 17 | const outputPath = 'src/generated/schemas/configuration.schema.json'; 18 | const schema = generator.createSchema(config.type); 19 | fs.mkdirSync('src/generated/schemas', { recursive: true }); 20 | fs.writeFileSync(outputPath, JSON.stringify(schema, null, 2)); -------------------------------------------------------------------------------- /src/scripts/injectpackage-env/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S pnpm tsx 2 | 3 | import { spawn } from 'child_process' 4 | import packageJson from '../../../package.json' 5 | 6 | process.env.PLASMO_PUBLIC_PACKAGE_VERSION = packageJson.version 7 | process.env.PLASMO_PUBLIC_PACKAGE_NAME = packageJson.name 8 | 9 | const cmd = spawn(process.argv.slice(2).join(" "), { shell: true }); 10 | 11 | cmd.stdout.on('data', (data) => { 12 | process.stdout.write(`${data}`); 13 | }); 14 | 15 | cmd.stderr.on('data', (data) => { 16 | process.stderr.write(`${data}`); 17 | }); 18 | -------------------------------------------------------------------------------- /src/storage/local/configuration/backend.ts: -------------------------------------------------------------------------------- 1 | import { assignPartial } from "~utils/generics" 2 | 3 | export type BackendConfigurationType = { 4 | matchPatterns: string[] 5 | traceCollectorUrl: string 6 | logCollectorUrl: string 7 | // metricsEnabled: boolean 8 | // metricCollectorUrl: string 9 | attributes: Map 10 | headers: Map 11 | } 12 | 13 | export type MapOrRecord = Map | Record 14 | export type BackendConfigurationProps = Partial 15 | 16 | export class BackendConfiguration implements BackendConfigurationType { 17 | matchPatterns = ['http://localhost/*']; 18 | traceCollectorUrl = 'http://localhost:4318/v1/traces'; 19 | logCollectorUrl = 'http://localhost:4318/v1/logs'; 20 | // metricCollectorUrl = 'http://localhost:4318/v1/metrics'; 21 | headers = new Map([ 22 | ['x-example-header', 'value'] 23 | ]); 24 | attributes = new Map([ 25 | ['key', 'value'] 26 | ]); 27 | 28 | constructor({ headers, attributes, ...params }: BackendConfigurationProps = {}) { 29 | assignPartial(this, params as Partial); 30 | 31 | if (headers instanceof Map) { 32 | this.headers = headers; 33 | } else if (typeof headers == 'object') { 34 | this.headers = new Map(Object.entries(headers)); 35 | } 36 | 37 | if (attributes instanceof Map) { 38 | this.attributes = attributes; 39 | } else if (typeof attributes == 'object') { 40 | this.attributes = new Map(Object.entries(attributes)); 41 | } 42 | } 43 | } 44 | 45 | export const defaultBackendConfiguration = new BackendConfiguration(); -------------------------------------------------------------------------------- /src/storage/local/configuration/configuration.test.ts: -------------------------------------------------------------------------------- 1 | import { hasMixin } from "ts-mixer"; 2 | import { Configuration } from "./configuration"; 3 | import { describe } from "mocha"; 4 | import { assert } from "chai"; 5 | import { ContentScriptConfiguration } from "./content-script"; 6 | import { BackendConfiguration } from "./backend"; 7 | 8 | describe('Configuration', () => { 9 | 10 | const test = new Configuration() 11 | 12 | it('should be an instance of BackendConfiguration', () => { 13 | assert(hasMixin(test, BackendConfiguration)) 14 | }) 15 | 16 | it('should be an instance of ContentScriptConfiguration', () => { 17 | assert(hasMixin(test, ContentScriptConfiguration)) 18 | }) 19 | 20 | it('should have the properties and default values of BackendConfiguration and ContentScriptConfiguration', () => { 21 | const backendKeys = Object.keys(new BackendConfiguration()) 22 | const csKeys = Object.keys(new ContentScriptConfiguration()) 23 | 24 | assert.containsAllKeys(test, [...backendKeys, ...csKeys]) 25 | 26 | backendKeys.forEach(key => { 27 | assert.deepEqual(test[key], new BackendConfiguration()[key]) 28 | }) 29 | 30 | csKeys.forEach(key => { 31 | assert.deepEqual(test[key], new ContentScriptConfiguration()[key]) 32 | }) 33 | }) 34 | }) -------------------------------------------------------------------------------- /src/storage/local/configuration/configuration.ts: -------------------------------------------------------------------------------- 1 | import { Mixin } from "ts-mixer"; 2 | import { BackendConfiguration, type BackendConfigurationType } from "./backend"; 3 | import { ContentScriptConfiguration, type ContentScriptConfigurationType } from "./content-script"; 4 | 5 | export type ConfigurationType = BackendConfigurationType & ContentScriptConfigurationType 6 | export class Configuration extends Mixin(BackendConfiguration, ContentScriptConfiguration) implements ConfigurationType { } 7 | export const defaultConfiguration = new Configuration(); 8 | -------------------------------------------------------------------------------- /src/storage/local/configuration/content-script.ts: -------------------------------------------------------------------------------- 1 | import { assignPartial } from "~utils/generics"; 2 | 3 | export type ContentScriptConfigurationType = { 4 | enabled: boolean; 5 | tracingEnabled: boolean; 6 | loggingEnabled: boolean; 7 | // metricsEnabled: boolean; 8 | instrumentations: ("load" | "fetch" | "interaction")[]; 9 | propagateTo: string[]; 10 | events: (keyof HTMLElementEventMap)[]; 11 | concurrencyLimit: number; 12 | attributes: Map 13 | } 14 | 15 | export class ContentScriptConfiguration implements ContentScriptConfigurationType { 16 | enabled = true; 17 | tracingEnabled = true; 18 | loggingEnabled = true; 19 | // metricsEnabled: boolean = true; 20 | instrumentations: ("load" | "fetch" | "interaction")[] = ['load', 'fetch', 'interaction']; 21 | propagateTo: string[] = []; 22 | events: (keyof HTMLElementEventMap)[] = ['submit', 'click', 'keypress', 'scroll', 'resize', 'contextmenu', 'drag', 'cut', 'copy', 'input', 'pointerdown', 'pointerenter', 'pointerleave']; 23 | concurrencyLimit = 50; 24 | // TODO: get rid of passing this to content script (parse proto in background script -> apply attributes to object -> re-encode) 25 | attributes = new Map([ 26 | ['key', 'value'] 27 | ]); 28 | 29 | constructor(params?: Partial) { 30 | assignPartial(this, params) 31 | } 32 | } 33 | 34 | export const defaultContentScriptConfiguration = new ContentScriptConfiguration(); -------------------------------------------------------------------------------- /src/storage/local/configuration/index.ts: -------------------------------------------------------------------------------- 1 | import { defaultBackendConfiguration } from "./backend" 2 | import { defaultContentScriptConfiguration, type ContentScriptConfigurationType } from "./content-script" 3 | import type { ConfigurationType } from "./configuration" 4 | import { assignPartial } from "~utils/generics"; 5 | 6 | export { Configuration, defaultConfiguration } from "./configuration"; 7 | export type { ConfigurationType } from "./configuration"; 8 | 9 | export { BackendConfiguration } from "./backend"; 10 | export type { BackendConfigurationType } from "./backend"; 11 | 12 | export { ContentScriptConfiguration } from "./content-script"; 13 | export type { ContentScriptConfigurationType } from "./content-script"; 14 | 15 | /** 16 | * @title Configuration 17 | * @description User-facing settings for the extension 18 | */ 19 | export type UserFacingConfigurationType = { 20 | /** 21 | * @description Whether the extension is enabled. 22 | */ 23 | enabled: boolean 24 | /** 25 | * @description List of [match patterns](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns) for which the extension should be enabled. 26 | * @example ["http://localhost/*", "https://*.example.com/*"] 27 | */ 28 | matchPatterns: string[] 29 | /** 30 | * @description [Attributes](https://opentelemetry.io/docs/specs/semconv/general/attributes/) to be added to all traces. 31 | * @example { "key": "value" } 32 | */ 33 | attributes: Record 34 | /** 35 | * @description HTTP headers to be added to requests when exporting collected telemetry. 36 | * @example { "x-example-header": "value" } 37 | */ 38 | headers: Record 39 | /** 40 | * @description List of regular expressions to match against outbound request URLs for which trace context should be forwarded. 41 | * @example ["https://example.com/.*"] 42 | */ 43 | propagateTo: string[] 44 | /** 45 | * @description Maximum number of concurrent requests that can be queued for export. 46 | * @example 50 47 | */ 48 | concurrencyLimit: number 49 | 50 | tracing: UserFacingTracingConfigurationType, 51 | logging: UserFacingLoggingConfigurationType, 52 | // TODO: implement 53 | // metrics: { 54 | // enabled: boolean 55 | // collectorUrl: string 56 | // } 57 | } 58 | 59 | /** 60 | * @description Configuration for trace telemetry. 61 | */ 62 | export type UserFacingTracingConfigurationType = { 63 | /** 64 | * @description Whether tracing is enabled. 65 | */ 66 | enabled: boolean 67 | /** 68 | * @description URL to which traces should be exported. Must accept Protobuf-encoded OTLP traces over HTTP. 69 | * @example "http://localhost:4318/v1/traces" 70 | */ 71 | collectorUrl: string 72 | /** 73 | * @description List of [browser events](https://azuresdkdocs.blob.core.windows.net/$web/javascript/azure-app-configuration/1.1.0/interfaces/htmlelementeventmap.html) to track (if 'interaction' instrumentation is enabled). 74 | * @example ["submit", "click", "keypress"] 75 | */ 76 | events: ContentScriptConfigurationType["events"] 77 | /** 78 | * @description List of automatic instrumentations to enable. 79 | */ 80 | instrumentations: ContentScriptConfigurationType["instrumentations"] 81 | } 82 | 83 | /** 84 | * @description Configuration for logging telemetry. 85 | */ 86 | export type UserFacingLoggingConfigurationType = { 87 | /** 88 | * @description Whether logging is enabled. 89 | */ 90 | enabled: boolean 91 | /** 92 | * @description URL to which logs should be exported. Must accept Protobuf-encoded OTLP logs over HTTP. 93 | * @example "http://localhost:4318/v1/logs" 94 | */ 95 | collectorUrl: string 96 | } 97 | 98 | export class UserFacingConfiguration implements UserFacingConfigurationType { 99 | enabled = defaultContentScriptConfiguration.enabled 100 | matchPatterns = defaultBackendConfiguration.matchPatterns 101 | attributes: Record = Object.fromEntries(defaultBackendConfiguration.attributes.entries()) 102 | headers: Record = Object.fromEntries(defaultBackendConfiguration.headers.entries()) 103 | propagateTo = defaultContentScriptConfiguration.propagateTo 104 | concurrencyLimit = defaultContentScriptConfiguration.concurrencyLimit 105 | tracing = { 106 | enabled: defaultContentScriptConfiguration.tracingEnabled, 107 | collectorUrl: defaultBackendConfiguration.traceCollectorUrl, 108 | events: defaultContentScriptConfiguration.events, 109 | instrumentations: defaultContentScriptConfiguration.instrumentations 110 | } 111 | logging = { 112 | enabled: defaultContentScriptConfiguration.loggingEnabled, 113 | collectorUrl: defaultBackendConfiguration.logCollectorUrl 114 | } 115 | 116 | constructor(params?: Partial) { 117 | assignPartial(this, params) 118 | } 119 | 120 | static from(stored: ConfigurationType): UserFacingConfiguration { 121 | return new UserFacingConfiguration({ 122 | enabled: stored.enabled, 123 | matchPatterns: stored.matchPatterns, 124 | attributes: Object.fromEntries(stored.attributes.entries()), 125 | headers: Object.fromEntries(stored.headers.entries()), 126 | propagateTo: stored.propagateTo, 127 | concurrencyLimit: stored.concurrencyLimit, 128 | tracing: { 129 | enabled: stored.tracingEnabled, 130 | collectorUrl: stored.traceCollectorUrl, 131 | events: stored.events, 132 | instrumentations: stored.instrumentations 133 | }, 134 | logging: { 135 | enabled: stored.loggingEnabled, 136 | collectorUrl: stored.logCollectorUrl 137 | } 138 | }) 139 | } 140 | 141 | serializable(): ConfigurationType { 142 | return { 143 | enabled: this.enabled, 144 | matchPatterns: this.matchPatterns, 145 | attributes: new Map(Object.entries(this.attributes)), 146 | headers: new Map(Object.entries(this.headers)), 147 | propagateTo: this.propagateTo, 148 | concurrencyLimit: this.concurrencyLimit, 149 | tracingEnabled: this.tracing.enabled, 150 | traceCollectorUrl: this.tracing.collectorUrl, 151 | events: this.tracing.events, 152 | instrumentations: this.tracing.instrumentations, 153 | loggingEnabled: this.logging.enabled, 154 | logCollectorUrl: this.logging.collectorUrl 155 | } 156 | } 157 | } 158 | 159 | export const defaultUserFacingConfiguration = new UserFacingConfiguration() -------------------------------------------------------------------------------- /src/storage/local/index.test.ts: -------------------------------------------------------------------------------- 1 | import { hasMixin } from "ts-mixer"; 2 | import { InternalStorage } from "./internal"; 3 | import { Configuration } from "./configuration"; 4 | import { describe } from "mocha"; 5 | import { assert } from "chai"; 6 | import { LocalStorage } from "."; 7 | 8 | describe('LocalStorage', () => { 9 | 10 | const test = new LocalStorage() 11 | 12 | it('should be an instance of Configuration', () => { 13 | assert(hasMixin(test, Configuration)) 14 | }) 15 | 16 | it('should be an instance of InternalStorage', () => { 17 | assert(hasMixin(test, InternalStorage)) 18 | }) 19 | 20 | it('should have the properties and default values of Configuration and InternalStorage', () => { 21 | const configurationKeys = Object.keys(new Configuration()) 22 | const internalStorageKeys = Object.keys(new InternalStorage()) 23 | 24 | assert.containsAllKeys(test, [...configurationKeys, ...internalStorageKeys]) 25 | 26 | configurationKeys.forEach(key => { 27 | assert.deepEqual(test[key], new Configuration()[key]) 28 | }) 29 | 30 | internalStorageKeys.forEach(key => { 31 | assert.deepEqual(test[key], new InternalStorage()[key]) 32 | }) 33 | }) 34 | }) -------------------------------------------------------------------------------- /src/storage/local/index.ts: -------------------------------------------------------------------------------- 1 | import type { KeyValues, Values } from "~types" 2 | import { de, ser } from "~utils/serde"; 3 | import { Configuration, type ConfigurationType } from "~storage/local/configuration"; 4 | import { InternalStorage, type InternalStorageType } from "./internal"; 5 | import { Mixin } from 'ts-mixer'; 6 | import { consoleProxy } from "~utils/logging"; 7 | 8 | export type LocalStorageType = ConfigurationType & InternalStorageType; 9 | export class LocalStorage extends Mixin(Configuration, InternalStorage) implements LocalStorageType { } 10 | export const defaultLocalStorage = new LocalStorage(); 11 | 12 | export const parseStorageResponse = (response: Record): Record => { 13 | return Object.entries(response).reduce((acc, [key, value]) => { 14 | 15 | if (typeof value !== 'string') { 16 | return { ...acc, [key]: value }; 17 | } 18 | else { 19 | try { 20 | return { ...acc, [key]: de(value) }; 21 | } catch (e) { 22 | return { ...acc, [key]: value }; 23 | } 24 | } 25 | }, {}); 26 | } 27 | 28 | export async function removeLocalStorage(keys: (keyof LocalStorage)[]): Promise { 29 | consoleProxy.debug(`removing local storage`, { keys }); 30 | return await chrome.storage.local.remove(keys); 31 | } 32 | 33 | export async function setLocalStorage>(data: T): Promise { 34 | return await setStorage('local', data); 35 | } 36 | 37 | export type ExtractLocalStorageKeys = ExtractKeysFrom; 38 | 39 | export type ExtractKeysFrom = T extends (keyof K)[] 40 | ? Pick 41 | : K; 42 | 43 | export async function getLocalStorage(keys?: T): Promise> { 44 | if (!keys) { 45 | return await getStorage('local', defaultLocalStorage) as ExtractLocalStorageKeys; 46 | } 47 | 48 | const selectedStorage = keys.reduce((acc, key) => { 49 | acc[key] = defaultLocalStorage[key]; 50 | return acc; 51 | }, {} as Partial>); 52 | 53 | return await getStorage('local', selectedStorage) as ExtractLocalStorageKeys; 54 | } 55 | 56 | export async function getStorage>(storageArea: chrome.storage.AreaName, keysWithDefaults: T): Promise { 57 | const serializedDefaults = Object.entries(keysWithDefaults).reduce((acc, [key, value]) => { 58 | return { ...acc, [key]: ser(value) }; 59 | }, {}); 60 | consoleProxy.debug(`getting storage`, { storageArea, keysWithDefaults, serializedDefaults }); 61 | const parsed = parseStorageResponse(await chrome.storage[storageArea].get(serializedDefaults)) as T; 62 | consoleProxy.debug(`got storage`, parsed); 63 | return parsed; 64 | } 65 | 66 | export async function setStorage(storageArea: chrome.storage.AreaName, data: T): Promise { 67 | const serialized = Object.entries(data).reduce((acc, [key, value]) => { 68 | return { ...acc, [key]: ser(value) }; 69 | }, {}); 70 | consoleProxy.debug(`setting storage`, { storageArea, data, serialized }); 71 | return await chrome.storage[storageArea].set(serialized); 72 | } 73 | -------------------------------------------------------------------------------- /src/storage/local/internal.ts: -------------------------------------------------------------------------------- 1 | import type { KeyValues } from "~types" 2 | import { defaultUserFacingConfiguration } from "./configuration" 3 | import { ser } from "~utils/serde" 4 | 5 | export type MatchPatternError = { 6 | error: string 7 | pattern: string 8 | } 9 | 10 | export enum ConfigMode { 11 | Visual = 'visual', 12 | Code = 'code' 13 | } 14 | 15 | // Data in LocalStorage used internally by the extension 16 | export type InternalStorageType = { 17 | matchPatternErrors: MatchPatternError[] 18 | // traceExportErrors?: string[] 19 | // logExportErrors?: string[] 20 | // metricExportErrors?: string[] 21 | configMode: 'visual' | 'code' 22 | configText: string 23 | editorState: KeyValues | null 24 | } 25 | 26 | export class InternalStorage implements InternalStorageType { 27 | matchPatternErrors: MatchPatternError[] = [] 28 | // traceExportErrors?: string[] = [] // TODO: 29 | // logExportErrors?: string[] = [] // TODO: 30 | // metricExportErrors?: string[] = [] // TODO: 31 | configMode = ConfigMode.Visual 32 | configText = ser(defaultUserFacingConfiguration, true) 33 | editorState: KeyValues | null = null 34 | } 35 | 36 | export const defaultInternalStorage = new InternalStorage(); -------------------------------------------------------------------------------- /src/tabs/preview.css: -------------------------------------------------------------------------------- 1 | .popup-container { 2 | width: 640px; 3 | } -------------------------------------------------------------------------------- /src/tabs/preview.tsx: -------------------------------------------------------------------------------- 1 | import IndexPopup from "~popup" 2 | 3 | import '~tabs/preview.css' 4 | 5 | export default function PreviewPage() { 6 | return 7 | // return process.env.NODE_ENV === 'development' ? : null 8 | } -------------------------------------------------------------------------------- /src/telemetry/logs.ts: -------------------------------------------------------------------------------- 1 | import { SeverityNumber } from "@opentelemetry/api-logs"; 2 | import { LoggerProvider } from "@opentelemetry/sdk-logs"; 3 | import { logPrefix } from "~utils/constants"; 4 | import { ser } from "~utils/serde"; 5 | 6 | 7 | export const wrapConsoleWithLoggerProvider = (provider: LoggerProvider) => { 8 | const logger = provider.getLogger(logPrefix); 9 | const shutdown = provider.shutdown 10 | const original = console 11 | 12 | const targets = { 13 | debug: [SeverityNumber.DEBUG, 'debug'], 14 | log: [SeverityNumber.INFO, 'info'], 15 | info: [SeverityNumber.INFO, 'info'], 16 | warn: [SeverityNumber.WARN, 'warn'], 17 | error: [SeverityNumber.ERROR, 'error'], 18 | trace: [SeverityNumber.TRACE, 'trace'], 19 | // console.table + what to do with other console methods? 20 | } 21 | 22 | // Wrap console methods with logger 23 | const proxy = new Proxy(console, { 24 | get: function (target, prop, receiver) { 25 | 26 | if (prop in targets) { 27 | 28 | return function (...args) { 29 | const [severityNumber, severityText] = targets[prop] 30 | logger.emit({ severityNumber, severityText, body: ser(args) }); 31 | target[prop].apply(target, args); 32 | }; 33 | } else { 34 | return Reflect.get(target, prop, receiver); 35 | } 36 | } 37 | }); 38 | 39 | // Replace the original console with the proxy 40 | console = proxy; 41 | 42 | // Clean-up if provider is unregistered 43 | provider.shutdown = async () => { 44 | window.console = original 45 | provider.shutdown = shutdown 46 | return await shutdown() 47 | } 48 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { LocalStorageType } from "~storage/local" 2 | import type { ContentScriptConfigurationType } from "~storage/local/configuration" 3 | 4 | declare global { 5 | interface Window { 6 | __OTEL_BROWSER_EXT_INSTRUMENTATION__: (() => void) | undefined 7 | } 8 | } 9 | 10 | /** 11 | * Enumerates all messages sent between background, content scripts, and the relay. 12 | */ 13 | export enum MessageTypes { 14 | OTLPTraceMessage = 'trace', 15 | OTLPLogMessage = 'log', 16 | OTLPMetricMessage = 'metric', 17 | StorageChanged = 'storageChanged', 18 | ConfigurationChanged = 'configChanged', 19 | Disconnect = 'disconnect' 20 | } 21 | 22 | export type ToBackgroundMessage = OTLPExportTraceMessage | OTLPExportLogMessage | OTLPMetricMessage 23 | export type ToRelayMessage = StorageChangedMessage 24 | export type ToContentScriptMessage = ConfigurationChangedMessage | DisconnectMessage 25 | export type CustomAppEvent = Omit & { 26 | detail: ToContentScriptMessage 27 | } 28 | 29 | export interface PortMessageBase { 30 | type: MessageTypes 31 | } 32 | 33 | export interface TypedPort extends chrome.runtime.Port { 34 | postMessage: (message: Send) => void 35 | onMessage: TypedMessageHandler 36 | } 37 | 38 | export interface TypedMessageHandler extends chrome.runtime.PortMessageEvent { 39 | addListener: (callback: (message: T, port: any) => void) => void 40 | } 41 | 42 | export interface OTLPExportTraceMessage extends PortMessageBase { 43 | type: MessageTypes.OTLPTraceMessage 44 | bytes: number[], 45 | } 46 | 47 | export interface OTLPExportLogMessage extends PortMessageBase { 48 | type: MessageTypes.OTLPLogMessage 49 | bytes: number[], 50 | } 51 | 52 | export interface OTLPMetricMessage extends PortMessageBase { 53 | type: MessageTypes.OTLPMetricMessage 54 | bytes: number[], 55 | } 56 | 57 | export interface StorageChangedMessage extends PortMessageBase { 58 | type: MessageTypes.StorageChanged 59 | data: Partial 60 | } 61 | 62 | export interface ConfigurationChangedMessage extends PortMessageBase { 63 | type: MessageTypes.ConfigurationChanged 64 | data: Partial 65 | } 66 | 67 | export interface DisconnectMessage extends PortMessageBase { 68 | type: MessageTypes.Disconnect 69 | } 70 | 71 | export type Primitive = string | number | boolean | null | undefined | object; 72 | export type Values = Primitive | Primitive[] | KeyValueStructure 73 | 74 | export type KeyValueStructure = { 75 | [key in string]: Values; 76 | }; 77 | 78 | export type KeyValues = KeyValueStructure; -------------------------------------------------------------------------------- /src/utils/background-ports.ts: -------------------------------------------------------------------------------- 1 | import type { ToBackgroundMessage, TypedPort, ToRelayMessage } from "~types"; 2 | 3 | let ports: Record> = {}; 4 | 5 | export const addPort = (port: chrome.runtime.Port) => { 6 | ports[port.sender.tab.id?.toString()] = port; 7 | } 8 | 9 | export const removePort = (port: chrome.runtime.Port) => { 10 | delete ports[port.sender.tab.id?.toString()]; 11 | } 12 | 13 | export const getPorts = () => ports; -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const events = [ 3 | "abort", 4 | "animationcancel", 5 | "animationend", 6 | "animationiteration", 7 | "animationstart", 8 | "auxclick", 9 | "blur", 10 | "cancel", 11 | "canplay", 12 | "canplaythrough", 13 | "change", 14 | "click", 15 | "close", 16 | "contextmenu", 17 | "copy", 18 | "cuechange", 19 | "cut", 20 | "dblclick", 21 | "drag", 22 | "dragend", 23 | "dragenter", 24 | "dragexit", 25 | "dragleave", 26 | "dragover", 27 | "dragstart", 28 | "drop", 29 | "durationchange", 30 | "emptied", 31 | "ended", 32 | "error", 33 | "focus", 34 | "focusin", 35 | "focusout", 36 | "fullscreenchange", 37 | "fullscreenerror", 38 | "gotpointercapture", 39 | "input", 40 | "invalid", 41 | "keydown", 42 | "keypress", 43 | "keyup", 44 | "load", 45 | "loadeddata", 46 | "loadedmetadata", 47 | "loadend", 48 | "loadstart", 49 | "lostpointercapture", 50 | "mousedown", 51 | "mouseenter", 52 | "mouseleave", 53 | "mousemove", 54 | "mouseout", 55 | "mouseover", 56 | "mouseup", 57 | "paste", 58 | "pause", 59 | "play", 60 | "playing", 61 | "pointercancel", 62 | "pointerdown", 63 | "pointerenter", 64 | "pointerleave", 65 | "pointermove", 66 | "pointerout", 67 | "pointerover", 68 | "pointerup", 69 | "progress", 70 | "ratechange", 71 | "reset", 72 | "resize", 73 | "scroll", 74 | "securitypolicyviolation", 75 | "seeked", 76 | "seeking", 77 | "select", 78 | "selectionchange", 79 | "selectstart", 80 | "stalled", 81 | "submit", 82 | "suspend", 83 | "timeupdate", 84 | "toggle", 85 | "touchcancel", 86 | "touchend", 87 | "touchmove", 88 | "touchstart", 89 | "transitioncancel", 90 | "transitionend", 91 | "transitionrun", 92 | "transitionstart", 93 | "volumechange", 94 | "waiting", 95 | "wheel" 96 | ] 97 | 98 | export const logPrefix = '[browser-extension-for-opentelemetry] %s' -------------------------------------------------------------------------------- /src/utils/generics.ts: -------------------------------------------------------------------------------- 1 | import { consoleProxy } from "./logging"; 2 | 3 | export const assignPartial = (instance: T, params?: Partial): void => { 4 | if (!params) { 5 | return; 6 | } 7 | 8 | Object.entries(params).forEach(([key, value]: [string, any]) => { 9 | // Check if the key exists on the instance 10 | if (Reflect.has(instance, key)) { 11 | 12 | if (value.constructor == instance[key].constructor) { 13 | instance[key] = value; 14 | } else { 15 | consoleProxy.warn(`Invalid constructor for ${key}: ${value} (have: ${value.constructor}, expected: ${instance[key].constructor})`); 16 | } 17 | } else { 18 | consoleProxy.warn(`Invalid key: ${key}, expected one of: ${Object.keys(instance)}`); 19 | } 20 | }); 21 | } 22 | 23 | export const pick = ( 24 | data: Data, 25 | keys: Keys[] 26 | ): Pick => { 27 | const result = {} as Pick; 28 | 29 | for (const key of keys) { 30 | 31 | if (Reflect.has(data, key)) { 32 | result[key] = data[key]; 33 | } 34 | } 35 | return result; 36 | } -------------------------------------------------------------------------------- /src/utils/logging.ts: -------------------------------------------------------------------------------- 1 | import { logPrefix } from "./constants"; 2 | 3 | const noop = (...args) => { } 4 | 5 | export const consoleProxy = { 6 | log: process.env.NODE_ENV !== 'production' ? console.log.bind(console, logPrefix) : noop, 7 | debug: process.env.NODE_ENV !== 'production' ? console.debug.bind(console, logPrefix) : noop, 8 | info: console.info.bind(console, logPrefix), 9 | warn: console.warn.bind(console, logPrefix), 10 | error: console.error.bind(console, logPrefix) 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/match-pattern.test.ts: -------------------------------------------------------------------------------- 1 | import { match } from "~utils/match-pattern"; 2 | import { assert } from 'chai'; 3 | 4 | describe("match", () => { 5 | it("returns true if URL matches any pattern", () => { 6 | const url = "https://example.com"; 7 | const patterns = ["https://example.com/", "https://*.com/"]; 8 | 9 | const result = match(url, patterns); 10 | 11 | assert.equal(result, true); 12 | }); 13 | 14 | it("returns true if URL matches pattern", () => { 15 | const url = "https://example.com"; 16 | const patterns = [""]; 17 | 18 | const result = match(url, patterns); 19 | 20 | assert.equal(result, true); 21 | }); 22 | 23 | it("returns true if the URL matches up to fragment identifier", () => { 24 | const url = "https://example.com/path#fragment"; 25 | const patterns = ["https://example.com/path"]; 26 | 27 | const result = match(url, patterns); 28 | 29 | assert.equal(result, true); 30 | }); 31 | 32 | it("returns true if URL matches pattern without a port, even if URL contains a port", () => { 33 | const url = "https://example.com:8080/path/abc"; 34 | const patterns = ["https://example.com/*"]; 35 | 36 | const result = match(url, patterns); 37 | 38 | assert.equal(result, true); 39 | }) 40 | 41 | it("returns true if less-greedy match URL matches pattern with wildcard", () => { 42 | const url = "https://example.com/path/abc?query=abc"; 43 | const patterns = ["https://example.com/*abc"]; 44 | 45 | const result = match(url, patterns); 46 | 47 | assert.equal(result, true); 48 | }) 49 | 50 | it("returns true if URL matches wildcard pattern", () => { 51 | const url = "https://example.com/path/abc?query=abc"; 52 | const patterns = ["https://*/*"]; 53 | 54 | const result = match(url, patterns); 55 | 56 | assert.equal(result, true); 57 | }) 58 | 59 | it("returns false if URL has trailing slash but pattern does not", () => { 60 | const url = "https://example.com/path/"; 61 | const patterns = ["https://example.com/path"]; 62 | 63 | const result = match(url, patterns); 64 | 65 | assert.equal(result, false); 66 | }) 67 | 68 | it("returns false if the pattern specifies a port, even if the URL does (as match patterns ignore ports)", () => { 69 | const url = "https://example.com:8080/path"; 70 | const patterns = ["https://example.com:8080/"]; 71 | 72 | const result = match(url, patterns); 73 | 74 | assert.equal(result, false); 75 | }) 76 | 77 | it("returns false if URL does not match pattern completely with path", () => { 78 | const url = "https://example.com/path"; 79 | const patterns = ["https://example.com/"]; 80 | 81 | const result = match(url, patterns); 82 | 83 | assert.equal(result, false); 84 | }); 85 | 86 | it("returns false if URL does not match pattern completely with query", () => { 87 | const url = "https://example.com/path/abc?query=abc"; 88 | const patterns = ["https://example.com/*/abc"]; 89 | 90 | const result = match(url, patterns); 91 | 92 | assert.equal(result, false); 93 | }); 94 | 95 | it("returns false if URL does not match any pattern", () => { 96 | const url = "https://example.com"; 97 | const patterns = ["https://*.org/", "http://*.com/"]; 98 | 99 | const result = match(url, patterns); 100 | 101 | assert.equal(result, false); 102 | }); 103 | 104 | it("returns false if patterns array is empty", () => { 105 | const url = "https://example.com"; 106 | const patterns = []; 107 | 108 | const result = match(url, patterns); 109 | 110 | assert.equal(result, false); 111 | }); 112 | }); -------------------------------------------------------------------------------- /src/utils/match-pattern.ts: -------------------------------------------------------------------------------- 1 | import { matchPattern, presets } from 'browser-extension-url-match' 2 | import type { MatchPatternOptions } from 'browser-extension-url-match/dist/types'; 3 | import { consoleProxy } from '~utils/logging'; 4 | import type { MatchPatternError } from '~storage/local/internal'; 5 | 6 | // TODO: potentially link to testing website somewhere: https://clearlylocal.github.io/browser-extension-url-match/ 7 | 8 | type SyncMatchPatternPermissionsArgs = { 9 | prev: string[], 10 | next: string[], 11 | } 12 | 13 | export const validatePatternPermissions = async (patterns: string[]): Promise => { 14 | // find which patterns are currently missing permissions 15 | const contains = await Promise.all(patterns.map(async (pattern) => { 16 | let t = await chrome.permissions.contains({ 17 | origins: [pattern] 18 | }) 19 | return [t, pattern] 20 | })) 21 | 22 | return contains.filter(([t,]) => !t).map(([, pat]) => ({ 23 | pattern: pat, 24 | error: 'Permission missing or not granted' 25 | })) as MatchPatternError[] 26 | } 27 | 28 | export const validatePatterns = (patterns: string[]): [string[], MatchPatternError[]] => { 29 | let validPatterns: string[] = [] 30 | let patternErrors: MatchPatternError[] = [] 31 | 32 | // verify added patterns are valid 33 | for (const pattern of patterns) { 34 | const matcher = matchPattern(pattern, { ...presets.chrome }); 35 | 36 | if (!matcher.valid) { 37 | patternErrors.push({ 38 | pattern, 39 | error: matcher.error.message 40 | }) 41 | } else { 42 | validPatterns.push(pattern) 43 | } 44 | } 45 | return [validPatterns, patternErrors] 46 | } 47 | 48 | export const syncMatchPatternPermissions = async ({ prev, next }: SyncMatchPatternPermissionsArgs) => { 49 | consoleProxy.debug('match patterns changed', { prev, next }) 50 | 51 | const removed = prev.filter((x) => !next.includes(x)) 52 | 53 | // remove permissions for removed patterns 54 | try { 55 | if (removed.length > 0) { 56 | consoleProxy.debug('removing permissions for origins', { removed }) 57 | const result = await chrome.permissions.remove({ 58 | origins: removed 59 | }); 60 | consoleProxy.debug('removed permissions result', result) 61 | } 62 | } catch (e) { 63 | consoleProxy.error('error removing permissions', e) 64 | } 65 | 66 | let [validPatterns, _] = validatePatterns(next) 67 | 68 | consoleProxy.debug('requesting permissions for patterns', validPatterns) 69 | 70 | try { 71 | // add permissions for added patterns 72 | await chrome.permissions.request({ 73 | origins: validPatterns 74 | }); 75 | } catch (e) { 76 | consoleProxy.error('error requesting or checking permissions', e) 77 | } 78 | } 79 | 80 | export const match = (url: string | undefined, patterns: string[], options?: Partial) => { 81 | if (!url) return false 82 | 83 | for (let pattern of patterns) { 84 | const matcher = matchPattern(pattern, { ...presets.chrome, ...options }); 85 | 86 | if (matcher.valid) { 87 | if (matcher.match(url)) { 88 | return true; 89 | } 90 | } 91 | } 92 | return false; 93 | } 94 | -------------------------------------------------------------------------------- /src/utils/options.ts: -------------------------------------------------------------------------------- 1 | import { getLocalStorage, defaultLocalStorage } from "~storage/local" 2 | 3 | const getOptions = async () => { 4 | return await getLocalStorage() 5 | } 6 | 7 | export { 8 | defaultLocalStorage as defaultOptions, 9 | getOptions, 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/platform.ts: -------------------------------------------------------------------------------- 1 | let info: chrome.runtime.PlatformInfo; 2 | 3 | export const getPlatformInfo = async () => { 4 | if (!info) { 5 | info = await chrome.runtime.getPlatformInfo(); 6 | } 7 | return info; 8 | } 9 | 10 | const keyMap = { 11 | 'Mod': { 12 | mac: '⌘', 13 | default: 'Ctrl' 14 | }, 15 | 'Meta': { 16 | mac: '⌘', 17 | win: '⊞', 18 | default: 'Meta' 19 | }, 20 | 'Alt': { 21 | mac: '⌥', 22 | default: 'Alt' 23 | }, 24 | } 25 | 26 | export const toPlatformSpecificKeys = (keys: string[], platform: chrome.runtime.PlatformInfo | null) => { 27 | 28 | if (!platform) { 29 | return 30 | } 31 | 32 | return keys.map(key => { 33 | 34 | switch (key) { 35 | case 'Mod': 36 | case 'Alt': 37 | case 'Meta': 38 | return keyMap[key][platform?.os] || keyMap[key].default; 39 | default: 40 | return key; 41 | } 42 | }); 43 | } -------------------------------------------------------------------------------- /src/utils/serde.ts: -------------------------------------------------------------------------------- 1 | const userFacingReplacer = (key, value) => { 2 | if (value instanceof Map) { 3 | const object = {}; 4 | for (let [k, v] of value) { 5 | object[k] = v; 6 | } 7 | return object; 8 | } 9 | return value; 10 | } 11 | 12 | export const replacer = (key, value) => { 13 | if (value instanceof Map) { 14 | return { 15 | dataType: 'Map', 16 | value: Array.from(value.entries()), // or with spread: value: [...value] 17 | }; 18 | } 19 | return value; 20 | } 21 | 22 | export const reviver = (_, value) => { 23 | if (typeof value === 'object' && value !== null) { 24 | if (value.dataType === 'Map') { 25 | return new Map(value.value); 26 | } 27 | } 28 | return value; 29 | } 30 | 31 | export const ser = (value: T, userFacing: boolean = false): string => { 32 | if (userFacing) { 33 | return JSON.stringify(value, userFacingReplacer, 2); 34 | } 35 | return JSON.stringify(value, replacer); 36 | } 37 | 38 | export const de = (value: string, constructor?: new (args: Partial) => T): T => { 39 | let deserialized = JSON.parse(value, reviver); 40 | return constructor ? new constructor(deserialized) : deserialized; 41 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "exclude": [ 4 | "node_modules", 5 | "build", 6 | "coverage", 7 | "assets" 8 | ], 9 | "include": [ 10 | "src", 11 | ".plasmo/index.d.ts", 12 | ".plasmo/**/*", 13 | "./**/*.ts", 14 | "./**/*.tsx" 15 | ], 16 | "compilerOptions": { 17 | "paths": { 18 | "~*": [ 19 | "./src/*" 20 | ], 21 | "inlinefunc:*": [ 22 | "./src/*" 23 | ] 24 | }, 25 | "module": "ES6", 26 | "target": "ES6", 27 | "baseUrl": ".", 28 | "isolatedModules": true, 29 | "allowSyntheticDefaultImports": true, 30 | "strictNullChecks": true 31 | } 32 | } --------------------------------------------------------------------------------