CodeWorlds
Back to collections
Guide20 min readCodeWorlds Team

OpenReplay, session replay on your own server

OpenReplay 18.1.3: MIT tracker, AGPL v3 server, proprietary ee directory. Default masking behaviour, self-hosting requirements and the hosted pricing.

OpenReplay, session replay on your own server

OpenReplay records browser sessions and replays them alongside the console log, network requests, store state and performance metrics. The tracker is MIT licensed, the server is not: the repository defaults to AGPL v3, and the ee directory runs under a separate proprietary license whose text is not in the repository at all.

What OpenReplay records and what it does not

An OpenReplay recording is not a video. The tracker captures the initial DOM tree and then a stream of mutations, mouse events, scrolling, window resizes and form input. The player rebuilds the page inside the operator's browser. The practical consequence: a recording weighs a fraction of what video would weigh, but it depends on stylesheets and assets being reachable at replay time. If an image has disappeared from the server, the replayed session has a hole in it.

On top of that come four diagnostic streams. The first is the browser console together with unhandled exceptions; the tracker parses the call stack with error-stack-parser-es pinned at 2.0.1. The second is network requests, intercepted by wrapping fetch, XMLHttpRequest and, optionally, Axios instances you point at it. The third is performance metrics, computed by the web-vitals dependency in the ^5.3.0 range. The fourth is store state, available through separate plugins for Redux, Vuex, NgRx and Zustand.

Co-browsing, meaning watching and taking over a live session, is a separate feature. It lives in the @openreplay/tracker-assist plugin, which pulls in socket.io-client in the ^4.8.1 range plus fflate. It is not part of the tracker core and you install it deliberately.

What OpenReplay does not do: it collects no server-side events, it is not a log store, and it computes no cohort retention. It answers the question "what did this one user see before filing the bug", not the question "how does the population behave". That distinction comes back in the comparison section.

The server license is AGPL v3, the tracker license is MIT

This is the easiest point to get wrong, because a single repository carries three licensing regimes at once.

The LICENSE file at the root of openreplay/openreplay opens with the line "Copyright (c) 2021-2025 Asayer, Inc dba OpenReplay" and is neither plain MIT nor plain Apache. It is a 694-line composite document whose preamble lists four rules. Content under ee/ falls under the license defined in ee/LICENSE. Third-party components keep their own licenses. Some directories are MIT. Everything else defaults to the GNU Affero General Public License version 3. Further down, the file inlines the full MIT text and then the full AGPL v3 text. For disputes it gives the address license@openreplay.com.

Two things in that description are imprecise and I checked both. First, the preamble points at ee/LICENSE, and no such file exists on the main branch: raw.githubusercontent.com returns 404 for it, while ee/LICENSE.md returns 200. The pointer in the main license names a file that is not there. Second, the sentence about MIT directories names no directory, and the only per-directory license file I could find is precisely ee/LICENSE.md. The tracker/ directory does exist in the repository, but it contains no LICENSE file. In other words, the MIT promise for part of the code is not attached to any concrete path inside the repository.

The ee/LICENSE.md file is a few sentences long. It is titled "The OpenReplay Enterprise license", carries the note "Copyright (c) 2022 Asayer, Inc." and says that to license the Enterprise edition and use its additional features you must agree to the OpenReplay Enterprise License Agreement, and that you should write to sales@openreplay.com to do so. The agreement itself is not in the repository. So the code under ee/ is publicly visible, but the terms for using it live outside the repository and require a sales conversation. The same shape, an open core plus a restricted directory in the same tree, also shows up in SuperTokens and in Dokploy.

The third source, the contents of the published package, is consistent in the tracker's case. The @openreplay/tracker 18.1.3 archive contains a nineteen-line LICENSE file holding the MIT text with the note "Copyright (c) 2022 Asayer, Inc". The string "AGPL" does not appear in that file once. The license field in the npm registry reads MIT. The package ships real code, not just metadata: 176 files, 5,360,028 bytes unpacked. Declaration, bundled file and actual contents agree.

For an agency one question decides everything: may you offer OpenReplay to clients as a hosted service. AGPL v3 does not forbid that, unlike SSPL or BSL style licenses. What section 13 of the AGPL does impose is an obligation that is easy to forget: if you make a modified version available over a network, you must offer the users of that instance access to the corresponding source. Running an unmodified OpenReplay for your own company creates no publication duty. Running a patched version as a service for clients does. And regardless of that, the ee/ directory stays out of reach without a contract with the vendor.

Versions, the npm package and the state of the plugin family

The current tracker version is 18.1.3, published on 12 August 2026. The previous releases are 18.1.2 from 29 July, 18.1.0 from 2 July and 18.0.0 from 15 April 2026. The registry holds 357 versions, and next to the latest tag there are also beta pointing at 19.0.0-beta.0 and legacy pointing at 17.2.10. The server is numbered separately: the most recent tagged release in the repository is v1.27.0 from 20 August 2026. The release feed is cluttered with automatic main-backup-<date> tags, more than a dozen of them in the last two weeks; when looking for the actual server version you have to filter them out.

The tracker itself is heavier than the "analytics snippet" label suggests. The built ESM module dist/lib/index.js is 431,305 bytes, roughly 98 kilobytes after gzip. That is comparable to a mid-sized UI library and it has to be counted against your performance budget.

Code
Bash
# tracker core, the 12 August 2026 release
npm install @openreplay/tracker@18.1.3

# what the published package actually declares
npm view @openreplay/tracker@18.1.3 license dependencies dist.unpackedSize

# the co-browsing plugin, the only one in the family released in 2026
npm install @openreplay/tracker-assist@11.0.20

# peer dependency range checked before installing
npm view @openreplay/tracker-assist@11.0.20 peerDependencies

I checked the peer dependency ranges across this family one by one, and here they are clean. Every plugin declares @openreplay/tracker with an open-ended range: >=15.0.0-0 in tracker-assist, >=14.0.0 in tracker-graphql, >=13.0.0 in tracker-redux, >=12.0.0 in tracker-zustand, >=3.4.8 in tracker-vuex and tracker-ngrx. None of them conflicts with the 18.1.3 core. That is the opposite of what you see in, for example, the Opik family, where integration packages pin the core to a range below the current major.

The real problem in this family lies elsewhere and it is called abandonment. @openreplay/tracker-profiler sits at 3.0.1 from 13 September 2022, @openreplay/tracker-ngrx at 3.4.9 from the same day, @openreplay/tracker-vuex at 4.0.3 from 27 September 2022, and @openreplay/tracker-axios at 3.6.2 from 16 December 2022. That last one declares the peer dependency axios: "0.x", while current Axios is at 1.19.0. Installing that plugin in a project with current Axios ends in an unmet peer dependency warning or, with a plain npm install, a tree resolution error. Axios interception now works through the network.axiosInstances option in the core, so the separate plugin is unnecessary, but nobody marked it as deprecated.

Similarly, @openreplay/tracker-zustand at 1.1.1 from 2 April 2024 pulls zustand in the ^4.5.2 range, while Zustand is already at 5.0.15. @openreplay/tracker-graphql 4.1.0 from 22 July 2024 pulls @apollo/client in the ^3.9.5 range as a regular dependency rather than a peer one, which means a second copy of the Apollo client in the tree if your project already has one.

Anonymization: the defaults decide everything

Session recording is personal data processing, and the tracker's default settings decide whether a password or a card number ends up in the recording. The values below come from the unpacked 18.1.3 package, not from the documentation.

The tracker has three sanitization levels, shared between text nodes and form fields: Plain with value 0, Obscured with value 1 and Hidden with value 2. The Obscured level replaces every non-whitespace character with an asterisk, so the player shows the shape and length of the content but not the content. The Hidden level removes the node from the recording.

OptionDefault valueWhat it covers
defaultInputMode1 (Obscured)values of all form fields
obscureInputNumberstruefields with four digits in a row, when the mode is Plain
obscureInputEmailstrueemail-typed fields or ones containing an at sign, when the mode is Plain
obscureInputDatesfalsedate-typed fields, when the mode is Plain
obscureTextEmailstrueemail addresses in plain page text
obscureTextNumbersfalsedigits in plain page text
privateModefalsemasking everything except marked exceptions
network.capturePayloadfalserequest and response bodies
network.ignoreHeaderscookie, set-cookie, authorizationheaders skipped in the recording

Several conclusions follow from that table, and you want them before rollout rather than after an incident.

Form fields are masked by default, because defaultInputMode is Obscured. Password fields go further: the input module contains an explicit node.type === 'password' condition that forces the Hidden level regardless of other settings. A password in a correctly typed field never reaches the recording in any configuration.

The three obscureInput* heuristics are often read as doing the opposite of what they do. In the code they are evaluated only inside the inputMode === InputMode.Plain branch, meaning they kick in once you yourself remove the default masking. If you set defaultInputMode to 0 to see what users type in the cart, your remaining protection is a /\d\d\d\d/ test against the field value. A card number passes it, since it has four digits in a row. A three-digit CVV in a text field does not: there are no four adjacent digits, so it lands in the recording in the clear.

Text nodes are in a worse position than fields. Email addresses in page content are masked by default, but numbers are not. An account balance, an order number, a national ID or a phone number rendered as text goes into the recording unchanged until you set obscureTextNumbers: true or mark the container with an attribute.

While we are here: in the published module the default value of obscureTextEmails is declared twice, and differently in each place. The Sanitizer class has true in its own defaults, and the application-level default option list has false. The outcome depends on which object reaches the sanitizer constructor, and what reaches it is the raw user options object, not the merged application list. In practice the true from the Sanitizer class wins. If you care about predictability, set the option explicitly instead of relying on the default.

One last item from this section: the respectDoNotTrack option exists but has no default value. The code reads it straight from the options object, so until you set it, the browser's Do Not Track header is ignored.

A tracker configuration you can show to an auditor

The configuration below closes the gaps described above. Every field name comes from the type declaration files in the 18.1.3 package.

Code
TypeScript
import Tracker from '@openreplay/tracker'

const tracker = new Tracker({
  projectKey: process.env.NEXT_PUBLIC_OR_PROJECT_KEY as string,
  ingestPoint: 'https://openreplay.company.internal/ingest',
  respectDoNotTrack: true,
  // 0 = Plain, 1 = Obscured, 2 = Hidden
  defaultInputMode: 1,
  obscureInputNumbers: true,
  obscureInputEmails: true,
  obscureInputDates: true,
  obscureTextEmails: true,
  obscureTextNumbers: true,
  network: {
    capturePayload: false,
    failuresOnly: false,
    sessionTokenHeader: false,
    ignoreHeaders: ['cookie', 'set-cookie', 'authorization', 'x-api-key']
  }
})

await tracker.start({ userID: 'user-4821' })

Two details from that block. The ingestPoint field defaults to https://api.openreplay.com/ingest, so with your own server you have to override it, otherwise the data goes to the vendor's cloud. The defaultInputMode value is given as a number, because the InputMode object is not re-exported from the package entry point; from the sanitizer module only SanitizeLevel is exported, which is what you need inside domSanitizer.

Per-element levels are set with HTML attributes. The current names are data-openreplay-obscured and data-openreplay-hidden. The older data-openreplay-masked and data-openreplay-htmlmasked still work, but the tracker prints a deprecation warning for them. The data-openreplay-unmask attribute matters only when privateMode is on, and data-openreplay-label substitutes a readable field label in place of a selector.

Code
HTML
<div data-openreplay-obscured>Balance: 12,480.00 USD</div>

<section data-openreplay-hidden>
  <iframe src="https://payments.example.com/card"></iframe>
</section>

<input type="text" name="cvv" data-openreplay-hidden />
<input type="email" name="email" data-openreplay-label="email address" />

When a rule is too complex for attributes, there is domSanitizer, a function called for every element and returning a level. A higher level always wins over settings and over attributes. The network-side equivalent is network.sanitizer, which receives an object with the fields status, method, url, request and response, may modify them, and dropping the whole request from the recording is done by returning null.

Code
TypeScript
import { SanitizeLevel } from '@openreplay/tracker'

const tracker = new Tracker({
  projectKey: process.env.NEXT_PUBLIC_OR_PROJECT_KEY as string,
  domSanitizer: (node: Element) => {
    if (node.closest('[data-pii]')) return SanitizeLevel.Hidden
    if (node.classList.contains('amount')) return SanitizeLevel.Obscured
    return SanitizeLevel.Plain
  },
  network: {
    capturePayload: true,
    sanitizer: (data) => {
      if (data.url.includes('/api/payments')) return null
      data.url = data.url.replace(/token=[^&]+/, 'token=***')
      if (data.request.body) data.request.body = '[removed]'
      return data
    }
  }
})

After changing attributes at runtime you have to call tracker.resanitize(el). Without an argument the method walks the whole document tree and its cost grows linearly with tree size, so pass the highest element you changed. To inspect the current state there is tracker.checkSanitization(el), which returns a level or undefined when the element is not tracked.

Cost: your own server versus a hosted plan

The vendor gives one set of minimum requirements and repeats it on both deployment pages: 2 virtual cores, 8 GB of RAM, 50 GB of disk and x86 architecture. The documentation is explicit that below those values the backend services simply will not start. The Docker guide suggests a t3.large class instance or equivalent and describes that set as enough for low to moderate volume. The Kubernetes guide gives the same numbers but describes them as enough for moderate volume. The discrepancy is in the wording, not the numbers, and when planning you should take the more cautious reading.

How much space the recordings themselves take, the vendor does not say. Neither the deployment pages nor the pricing page carry a conversion of the "X megabytes per session" kind. The 50 GB disk is a startup threshold for the services in the documentation, not a capacity forecast. The only number you can anchor an estimate on comes from the hosted plan comparison: an O1 class instance gets 120 GB of disk, and the ceiling for the Dedicated plan is 4 TB. Without an official conversion rate, translating that into a session count would be guessing, so measure it yourself on a few hundred sessions from your own application before a production rollout.

The hosted pricing page fetched on 22 August 2026 lists three deployment options. Self-Hosted is free and amounts to running the open source code on your own hardware. Dedicated is a managed instance from 199 dollars per month, billed hourly at 0.276 dollars per hour. Serverless is described as billing based on the number of recorded sessions. The Serverless price cannot be read: that tab renders only once JavaScript runs, and the raw HTML contains neither a per-session rate nor a free threshold, so I will not quote a number I did not see.

The arithmetic of the Dedicated plan deserves attention. A rate of 0.276 dollars per hour times 720 hours gives 198.72 dollars, which rounds to the quoted 199 dollars. But a month averages 730 hours, and that multiplication gives 201.48 dollars. The quoted monthly price therefore assumes a thirty-day month, and hourly billing can come out slightly higher.

The O1 configuration at 199 dollars in the North Virginia region is 8 GB of RAM, 2 virtual cores and 120 GB of disk, meaning the deployment minimum plus a bigger disk. The vendor itself recommends starting at O2. Add a few conditions from the questions section of the pricing page: a stopped instance is still billed for its disk, the Bring-Your-Own-Cloud variant has a flat monthly rate independent of region, the trial runs seven days with no card, and migration between the open plan and the hosted one is not supported yet and requires talking to the team. There is no free cloud tier on that page; the only free option is self-hosting.

OpenReplay versus PostHog, Sentry and Axiom

These four tools get lumped together as "frontend observability", yet they answer different questions and operate on different units of data.

ToolQuestion it answersUnit of dataWhen you reach for it
OpenReplaywhat exactly this user saw and clickedbrowser sessiona "works on my machine" report with no reproduction steps
PostHoghow the user population behavesproduct eventfunnels, retention, A/B tests, replay as an extra
Sentrywhat broke and on which line of codeexception with a call stackalerting on errors and post-deploy regressions
Axiomwhat happened across the system in a time windowraw log eventquerying logs from the whole infrastructure

PostHog carries session replay as one feature among product analytics, and that shifts the centre of gravity. If the main job is a checkout funnel or an A/B test, and a recording is meant to be evidence in a specific case, PostHog is enough and saves you one integration. If the main job is debugging, then the console log, network request inspection and store state wired to the replay timeline are noticeably richer in OpenReplay.

Sentry and OpenReplay complement each other. Sentry tells you that release 4.2.1 throws the same exception for four hundred people and shows the call stack. OpenReplay shows what those people were doing for thirty seconds before the throw. The second part cannot be reconstructed from the error report alone.

Axiom operates at a different level of abstraction: it is an event store with a query language, not a player. It suits correlating frontend traffic with backend service behaviour better, but nobody watches a session in it.

It is also fair to state what OpenReplay will not replace. It is not a traffic analytics tool and it will not remove Plausible from your stack if you need simple cookie-free visit statistics. The analytics module built into the tracker is an addition, not a substitute.

When choosing a tool in this category, the fate of its closest competitor is worth knowing. Highlight.io offered the same combination of replays, errors and logs, with a self-hosted variant, but it was absorbed: the domain now redirects entirely to LaunchDarkly, the last image release dates from August 2025, and the browser library only ships on as an internal dependency of somebody else’s product. That illustrates a risk which an open-source, self-hosted tool reduces without removing: the code stays, but development and container images can stop.

Common mistakes during rollout

First: leaving the default ingestPoint. When self-hosting without overriding that field, the tracker sends sessions to api.openreplay.com, which is exactly where an on-premises deployment was supposed not to send them.

Second: dropping defaultInputMode to zero in the name of "better debugging" without reviewing the forms. From that moment the only protection is the heuristics, and they let through values shorter than four digits.

Third: assuming numbers on the page are masked the same way as numbers in fields. They are not. obscureTextNumbers defaults to false, and amounts, order numbers or identifiers in page content land in the recording in full.

Fourth: enabling network.capturePayload without writing a network.sanitizer function. API response bodies are the shortest path to having your entire customer table end up inside a session recording.

Fifth: installing plugins from the family without checking the release date. Four of them have sat untouched since 2022, and tracker-axios additionally blocks current Axios with its 0.x range.

Sixth: patching the server and running it as a service for clients without reading section 13 of AGPL v3. The license does not forbid it, but it does impose the duty to offer the source to the users of that instance.

Seventh: extending the deployment with features from the ee/ directory on the assumption that publicly visible code in an openly licensed repository is free to use. The ee/ directory has its own proprietary license, and the text of that license is not published.

Eighth: running the tracker outside HTTPS. The code refuses to start on a protocol other than https: and logs a console message, unless you set the __DISABLE_SECURE_MODE flag intended strictly for local testing. That flag is named the way it is on purpose and it should never reach production.

FAQ

Is OpenReplay fully open source?

Not entirely. The npm tracker is MIT, the server code defaults to AGPL v3, and the ee/ directory carries a proprietary license requiring an agreement with the vendor. The main LICENSE file also mentions unspecified MIT directories, but names none of them.

Can I host OpenReplay for clients as a service?

AGPL v3 does not forbid it, unlike SSPL or BSL. If you deploy a modified version, however, section 13 of the license requires making the source of that version available to users interacting with the instance over the network. Features from the ee/ directory stay outside that scenario.

Do passwords and card numbers end up in recordings?

Password-typed fields are always dropped, because a condition in the code forces it. Other fields are masked with asterisks by default. The risk appears after switching defaultInputMode to Plain, and in plain page text, where numbers are not masked by default.

How much space do session recordings take?

The vendor publishes no per-session figure. It gives deployment minimums, meaning 2 cores, 8 GB of RAM and 50 GB of disk, and the capacities of hosted plans, from 120 GB in the O1 configuration up to 4 TB in the Dedicated plan. Actual usage has to be measured against your own traffic.

Does the hosted plan have a free tier?

On the pricing page as of 22 August 2026 the only free option is self-hosting. The Dedicated plan starts at 199 dollars per month and offers a seven-day trial with no card. The Serverless plan terms render only after JavaScript runs and could not be read from the raw HTML.

Will OpenReplay replace PostHog or Sentry for me?

Probably not entirely. OpenReplay answers a question about a single session, PostHog about population behaviour, and Sentry about a specific exception in a specific release. In React codebases and in Next.js projects the common arrangement has Sentry watching errors while OpenReplay serves to replay reports that arrive with no reproduction steps.

Sources checked on 22 August 2026: the LICENSE file in the repository, package metadata in the npm registry, the deployment documentation and the vendor's pricing page.

Read next

We use cookies to enhance your experience on the site