Creating Custom Modules
This guide explains how to create custom modules for the Tealium Prism SDK. Custom modules allow you to extend the SDK’s functionality with your own data collection, processing, or dispatching logic.
Overview
There are two main approaches to creating custom modules:
- Using BasicModuleFactory - For simple modules with standard initialization patterns (only one module instance, no custom dependencies)
- Implementing ModuleFactory - For modules requiring custom dependencies or multiple instances
Using BasicModuleFactory
The BasicModuleFactory is the recommended approach for most custom modules. It provides a standardized way to create modules that conform to the BasicModule protocol.
Step 1: Create Your Custom Module
Your custom module must conform to BasicModule:
class MyCustomModule: BasicModule {
let version = "1.0.0"
let id = Self.moduleType
@StateSubject([:])
var moduleConfiguration: ObservableState<DataObject>
required init?(context: TealiumContext, moduleConfiguration: DataObject) {
self._moduleConfiguration.value = moduleConfiguration
// Validate required configuration
guard let apiKey = moduleConfiguration.getDataItem(key: "apiKey")?.get(as: String.self),
!apiKey.isEmpty else {
return nil // Return nil if initialization fails
}
// Initialize your module with the provided configuration
}
static let moduleType: String = "MyCustomModule"
}
Step 2: Create a Settings Builder (Optional)
For type-safe configuration, create a custom settings builder:
public class MyCustomModuleSettingsBuilder: ModuleSettingsBuilder {
enum Keys {
static let apiKey = "apiKey"
static let timeout = "timeout"
}
/// Sets the API key for the custom module.
///
/// - Parameter apiKey: The API key to use
/// - Returns: The builder instance for method chaining
public func setApiKey(_ apiKey: String) -> Self {
_configurationObject.set(apiKey, key: Keys.apiKey)
return self
}
/// Sets the timeout for network requests.
///
/// - Parameter timeout: The timeout in seconds
/// - Returns: The builder instance for method chaining
public func setTimeout(_ timeout: Int) -> Self {
_configurationObject.set(timeout, key: Keys.timeout)
return self
}
}
Step 3: Add Factory Method to Modules Extension
Add a factory method to the Modules extension:
public extension Modules {
static func myCustomModule(forcingSettings block: EnforcingSettings<MyCustomModuleSettingsBuilder>? = { $0 }) -> some ModuleFactory {
BasicModuleFactory<MyCustomModule>(
moduleType: Modules.Types.myCustomModule,
enforcedSettings: block?(MyCustomModuleSettingsBuilder()).build()
)
}
}
Step 4: Register Your Module
Add your module to the Tealium configuration:
var config = TealiumConfig(
account: "account",
profile: "profile",
environment: "dev"
)
// Add with default settings
config.addModule(Modules.myCustomModule())
// Or add with default settings
// but require this module to be defined in the Local or Remote settings to be initialized
config.addModule(Modules.myCustomModule(forcingSettings: nil))
// Or configure with specific settings
config.addModule(Modules.myCustomModule(forcingSettings: { builder in
builder.setApiKey("your-api-key")
.setTimeout(30)
.setEnabled(true)
}))
Custom ModuleFactory Implementation
For modules that need custom dependencies, multiple instances, or complex initialization logic, implement the ModuleFactory protocol directly:
public class CustomModuleFactory: ModuleFactory {
public let allowsMultipleInstances: Bool
public let moduleType: String
private let customDependency: SomeDependency
public init(customDependency: SomeDependency, allowsMultipleInstances: Bool = false) {
self.customDependency = customDependency
self.allowsMultipleInstances = allowsMultipleInstances
self.moduleType = "CustomModule"
}
public func create(moduleId: String, context: TealiumContext, moduleConfiguration: DataObject) -> CustomModule? {
CustomModule(context: context,
moduleConfiguration: moduleConfiguration,
customDependency: customDependency)
}
public func getEnforcedSettings() -> [DataObject] {
// Return enforced settings if needed
[]
}
}
Reaching Other Modules
A module reaches the other modules through the ModuleManager exposed on the TealiumContext as context.moduleManager.
These lookups are asynchronous, and there is deliberately no synchronous alternative, because of when a module is created.
The modules of a settings pass are created one at a time, so when your init runs the module you are looking for may not exist yet.
A synchronous answer at that point couldn’t tell “that module is not enabled” from “that module hasn’t been created yet”, so instead of handing back a premature nil, these methods withhold the answer until the pass has created every module.
getModule(completion:)— a one-shot lookup of a concrete module type, for when you need the module once, at a specific moment.
context.moduleManager.getModule { (other: OtherCustomModule?) in
// Called once, after the modules have been created
}.addTo(disposer)
observeModule(_:)— the same lookup, followed over time. Use it when the module can come and go: a settings update can enable or disable it, and each change is emitted.
context.moduleManager.observeModule(OtherCustomModule.self)
.subscribe { [weak self] other in self?.other = other }
.addTo(disposer)
- A transformation, with
observeModule(_:),observeModules(_:)orgetModule(transform:completion:)— the only way to reach a module you can’t name as a concrete type: one that you only know through a protocol you declare, or one of several instances. The block is applied to every module inModuleSettings.order; the single-module variants stop at the first one returning a non-nilvalue, whileobserveModulescollects them all.
class MyCustomModule: BasicModule {
private var provider: MyDataProviding?
private let disposer = Disposables.automatic()
required init?(context: TealiumContext, moduleConfiguration: DataObject) {
context.moduleManager.observeModule { $0 as? MyDataProviding }
.subscribe { [weak self] provider in self?.provider = provider }
.addTo(disposer)
}
}
Two behaviours are worth spelling out, as neither is visible from the signatures:
- Once one of these has started emitting, a
nil(or an empty array) means the module is not enabled. It never means “not created yet”. - After the SDK has been shut down you get a
nil(or an empty array) as well, rather than a lookup that never resolves.
The transformation variants re-emit only when the set of modules whose transformation succeeded changes, by identity or order. A value that varies over time for the same module is not re-emitted — observe it through the module itself.
Subscribe to all of these from the Tealium queue. Your module already runs on it, so inside a module there is nothing to do; to consume them from another queue, chain subscribeOn(_:) with the Tealium queue from TealiumContext.queue eventually followed by observeOn(_:) with yours.
Sending Network Requests
Modules that need to talk to the network (most commonly custom Dispatchers) do so through the NetworkUtilities exposed on the TealiumContext as context.network. Two entry points are available, both wired with the SDK’s logging and tracing. Each makes a single attempt per request and never retries it. To retry, derive a client with newClient(withAdditionalInterceptors:maxRetryCount:) using the interceptors from RequestInterceptors.retryWithBackoff() and/or RequestInterceptors.retryWhenConnected(connectivityManager:), and call makeNetworkHelper() on it for the helper API:
context.network.networkClient— the primary entry point for any non-standard request. Build a request withRequestBuilderand hand the builder tosendRequest(_:completion:); the client builds it for you, logs the outcome, and always calls the completion (a malformed URL or build failure completes with.failure(.unknown)and returns an already-disposedDisposable). Request bodies are uncompressed by default — opt into gzip only for endpoints that support it, viagzip().
// Uncompressed POST (safe for arbitrary endpoints)
let disposable = context.network.networkClient.sendRequest(
RequestBuilder.makePOST(url: endpoint, json: payload)
.header("Bearer \(token)", forField: "Authorization")
) { result in
switch result {
case .success(let response): // handle response
case .failure(let error): // handle error (including .cancelled on dispose)
}
}
// Opt into gzip compression when the endpoint supports it
_ = context.network.networkClient.sendRequest(
RequestBuilder.makePOST(url: endpoint, json: payload).gzip()
) { _ in }
You can also pass a fully-formed URLRequest to sendRequest(_:completion:) if you need complete control over the request.
context.network.networkHelper— a convenience short-circuit for standard, uncompressed GET/POST requests. Use it when you don’t need custom headers, compression, or a hand-builtURLRequest. For gzip or any customized request, usecontext.network.networkClientwith aRequestBuilderinstead.
_ = context.network.networkHelper.post(url: endpoint, body: payload) { result in
// handle result
}
Dispose the returned Disposable to cancel an in-flight request; the completion is then called with .failure(.cancelled).
Best Practices
- Module Type Constants: Define module type constants in
Modules.Typesfor consistency - Other Modules: Never assume the other modules exist while your
initruns — resolve them throughcontext.moduleManager, which withholds the answer until the modules of the current settings pass have been created - Validation: Always validate required configuration in your module’s initializer
- Return nil: Return
nilfrom the initializer if required configuration is missing or invalid - Settings Builders: Use
ModuleSettingsBuildersubclasses for type-safe configuration - Documentation: Document your module’s configuration options and behavior
Limitations of BasicModuleFactory
- Modules cannot be instantiated multiple times (
allowsMultipleInstancesis alwaysfalse) - No support for custom dependencies beyond
TealiumContext - Standard initialization pattern only
If you need these features, implement ModuleFactory directly.
View on GitHub