obsly_flutter 1.0.1
obsly_flutter: ^1.0.1 copied to clipboard
Advanced Flutter SDK for comprehensive user behavior analytics, UI event tracking, automatic screenshot capture with debug tools, and GoRouter navigation support.
Obsly Flutter SDK - Advanced User Behavior Analytics #

π Now supports MaterialApp, CupertinoApp, GoRouter, and custom Flutter architectures!
Overview #
Obsly delivers comprehensive user behavior analytics and observability for iOS, Android and Web applications. This Flutter SDK works seamlessly with any Flutter application architecture and provides intuitive initialization patterns.
π₯ New Framework-Agnostic Features #
- β MaterialApp Support - Full backward compatibility, zero breaking changes
- β CupertinoApp Support - Native iOS styling preserved, no forced MaterialApp wrapper
- β Custom Architecture Support - Works with any Flutter widget hierarchy
- β GoRouter Integration - Full support for GoRouter with automatic detection and UI event handling
- β Advanced Navigation - Ready for go_router, auto_route, and custom navigation systems
- β
Dio HTTP Client Support - Simple
dio.addObsly()integration for Dio HTTP monitoring - β Intuitive Integration - Clean API patterns with enhanced flexibility
- β Conflict Prevention - Safe coexistence with existing crash reporting systems
Core Capabilities #
- Automatic UI, navigation, network, console and crash capture
- HTTP client monitoring (native
httppackage anddiopackage) - Optional screenshot capture on UI and rage-click events
- Performance transactions and steps
- Custom metrics (counter, gauge, histogram timer)
- Tags, context, session and user identification
For API parity details, see the browser SDK documentation in the repository browser/README.md.
Table of Contents #
- Installation
- Framework-Agnostic Initialization
- App Wrapping (Any Architecture)
- Framework Examples
- MaterialApp Integration
- CupertinoApp Integration
- Custom Architecture Integration
- Advanced Features
- Navigation Integration
- Dio HTTP Client Integration
- Error Handling & Conflict Prevention
- Routing and Navigation Support
- GoRouter Integration
- Traditional Navigator Support
- Navigation Events Captured
- Custom Navigation Solutions
- Troubleshooting Navigation Issues
- Configuration
- Developer Methods
- Manage Sessions
- Client Identification
- Application Identification
- Tag
- Screenshot
- Performance
- Metrics
- Application Context
- SDK Control
- Miscellaneous
- Debug tools
- Migration Guide
- Requirements
- Getting Help
- License
Installation #
Add the dependency:
dependencies:
obsly_flutter: ^0.2.0
Framework-Agnostic Initialization #
π₯ Quick Setup (Recommended) #
The new API provides intuitive initialization patterns that work with any Flutter architecture:
import 'package:flutter/material.dart';
import 'package:obsly_flutter/obsly_sdk.dart';
void main() async {
// π₯ Optional: Configure async error capture (conflict-safe)
ObslySDK.enableAsyncErrorCapture(
global: true,
preventConflicts: true, // Safe with existing error reporting systems
);
// π₯ Initialize with error capture
ObslySDK.runWithAsyncErrorCapture(() async {
WidgetsFlutterBinding.ensureInitialized();
await ObslySDK.instance.init(
const InitParameters(
obslyKey: 'YOUR_OBSLY_KEY',
instanceURL: 'https://api.obsly.io',
debugMode: true,
logLevel: LogLevel.debug,
config: ObslyConfig(
enableDebugTools: true,
enableScreenshotOnUi: true,
rageClick: RageClickConfig(
active: true,
screenshot: true,
screenshotPercent: 0.25,
),
),
),
);
runApp(MyApp()); // Works with ANY Flutter app!
});
}
App Wrapping (Any Architecture) #
Framework-Agnostic Wrapping #
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Your app - MaterialApp, CupertinoApp, or Custom
final yourApp = MaterialApp(
title: 'My App',
home: HomePage(),
);
// π― Framework-agnostic wrapping - auto-detects app type
return ObslySDK.instance.wrapApp(
app: yourApp,
enableDebugTools: true,
);
}
}
Framework Examples #
MaterialApp Integration (Backward Compatible) #
class MaterialBankingApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final materialApp = MaterialApp(
title: 'Banking App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
initialRoute: '/',
routes: {
'/': (context) => LoginScreen(),
'/dashboard': (context) => DashboardScreen(),
'/transactions': (context) => TransactionsScreen(),
},
);
// β
All MaterialApp properties preserved exactly
return ObslySDK.instance.wrapApp(
app: materialApp,
enableDebugTools: true,
);
}
}
CupertinoApp Integration (iOS Native) #
class iOSBankingApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cupertinoApp = CupertinoApp(
title: 'iOS Banking',
theme: CupertinoThemeData(
primaryColor: CupertinoColors.systemBlue,
),
home: CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text('Banking'),
),
child: iOSHomeScreen(),
),
);
// β
Native iOS styling preserved, no MaterialApp forced
return ObslySDK.instance.wrapApp(
app: cupertinoApp,
enableDebugTools: true,
);
}
}
Custom Architecture Integration #
class CustomArchitectureApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final customApp = Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.purple, Colors.blue],
),
),
child: CustomNavigationSystem(
child: MyCustomScreens(),
),
);
// β
No forced MaterialApp wrapper
return ObslySDK.instance.wrapApp(
app: customApp,
customNavigationProvider: CustomNavigationProvider(),
enableDebugTools: true,
);
}
}
Advanced Features #
Navigation Integration #
go_router Support
final GoRouter _router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => HomeScreen()),
GoRoute(path: '/profile', builder: (context, state) => ProfileScreen()),
],
);
class GoRouterApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final app = MaterialApp.router(routerConfig: _router);
return ObslySDK.instance.wrapApp(
app: app,
customNavigationProvider: GoRouterProvider(), // π Coming soon
);
}
}
auto_route Support
@AutoRouterConfig()
class AppRouter extends _$AppRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: HomeRoute.page, path: '/'),
AutoRoute(page: ProfileRoute.page, path: '/profile'),
];
}
class AutoRouteApp extends StatelessWidget {
final _appRouter = AppRouter();
@override
Widget build(BuildContext context) {
final app = MaterialApp.router(routerConfig: _appRouter.config());
return ObslySDK.instance.wrapApp(
app: app,
customNavigationProvider: AutoRouteProvider(), // π Coming soon
);
}
}
Error Handling & Conflict Prevention #
Coexistence with Other Error Reporting
void main() async {
// Initialize your existing error reporting system
await initializeExistingErrorReporting();
// Initialize Obsly with conflict prevention
ObslySDK.enableAsyncErrorCapture(
global: true,
preventConflicts: true, // π‘οΈ Safe coexistence
);
ObslySDK.runWithAsyncErrorCapture(() async {
await ObslySDK.instance.init(params);
runApp(MyApp());
});
}
Custom Error Handling
void main() async {
// Configure with custom error handler
ObslySDK.enableAsyncErrorCapture(
global: true,
captureIsolateErrors: false,
preventConflicts: true,
);
// Listen to async errors
ObslySDK.onAsyncError.listen((error) {
print('Custom handling: ${error.error}');
// Your custom logic here
});
ObslySDK.runWithAsyncErrorCapture(() async {
await ObslySDK.instance.init(params);
runApp(MyApp());
});
}
Advanced Setup (Manual Control) #
For cases requiring granular initialization control:
import 'package:flutter/widgets.dart';
import 'package:obsly_flutter/obsly_sdk.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
ObslySDK.run(() async {
try {
await ObslySDK.instance.init(
InitParameters(
obslyKey: 'YOUR_OBSLY_KEY',
instanceURL: 'https://api.obsly.com',
appName: 'MyFlutterApp',
appVersion: '1.0.0',
logLevel: LogLevel.error,
debugMode: false,
config: const ObslyConfig(
enableAutomaticCapture: true,
enableScreenshotOnUi: false,
enableRageClickScreenshot: false,
),
// sessionID: 'optional-custom-session-id',
),
);
runApp(const MyApp());
} catch (e) {
// App continues without Obsly if initialization fails
runApp(const MyApp());
}
});
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ObslySDK.instance.wrapAppLegacy(
app: MaterialApp(
title: 'My App',
home: const HomePage(),
),
enableDebugTools: false, // set true to show the debug overlay
);
}
}
Dio HTTP Client Integration #
For applications using the popular Dio HTTP client, Obsly provides seamless integration with a simple extension API:
Basic Usage
import 'package:dio/dio.dart';
import 'package:obsly_flutter/obsly_sdk.dart';
class ApiClient {
final Dio dio;
ApiClient() : dio = Dio() {
dio.addObsly(); // β¨ One-liner integration
}
Future<Map<String, dynamic>> getUser(String id) async {
final response = await dio.get('/users/$id');
return response.data;
}
}
Advanced Configuration
class ApiService {
final Dio _publicApi;
final Dio _privateApi;
ApiService() :
_publicApi = Dio(BaseOptions(baseUrl: 'https://api.example.com')),
_privateApi = Dio(BaseOptions(baseUrl: 'https://internal.example.com')) {
// Monitor public API calls
_publicApi.addObsly();
// Private API without monitoring (skip addObsly())
_privateApi.interceptors.add(AuthInterceptor());
}
}
Features
- β Automatic Request/Response Capture: Monitors all Dio HTTP requests and responses
- β Error Tracking: Captures DioExceptions and HTTP errors
- β Timing Metrics: Records request duration and performance data
- β Duplicate Prevention: Intelligent detection prevents adding multiple Obsly interceptors
- β Zero Configuration: Works out of the box with existing Dio instances
- β Coexistence: Works alongside your existing Dio interceptors
Verification
final dio = Dio();
dio.addObsly();
// Check if monitoring is active (optional)
print('Obsly monitoring: ${dio.hasObslyMonitoring}'); // true
// Make requests - automatically monitored
await dio.get('https://api.example.com/data');
All HTTP requests made through Dio will now appear in your Obsly dashboard alongside native http package requests.
Configuration #
Init parameters (parity with JS Browser SDK):
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
ObslyKey |
String |
Yes | β | Authorization API key |
instanceURL |
String |
Yes | β | API server URL |
remoteConfigURL |
String? |
No | β | Remote config URL |
proEnv |
bool? |
No | β | Production environment flag |
appVersion |
String? |
No | β | App version |
appName |
String? |
No | β | App name |
logLevel |
String? |
No | "error" |
Allowed values: null, error, warn, log, debug |
config |
ObslyConfig? |
No | see below | Advanced configuration |
debugMode |
bool? |
No | false |
Enable debug overlay and verbose behavior |
sessionID |
String? |
No | β | Custom session ID on init |
ObslyConfig structure:
| Parameter | Type | Default | Description |
|---|---|---|---|
enableAutomaticCapture |
bool |
true |
Enable all automatic interceptors (UI, navigation, HTTP, console, crashes) |
enableDebugTools |
bool |
false |
Render floating debug tools when wrapping app |
logLevel |
LogLevel |
LogLevel.error |
Runtime log level (use setLogLevel for string-based) |
userId |
String? |
β | Pre-set user id |
appName |
String? |
β | Override app name |
appVersion |
String? |
β | Override app version |
enableScreenshotOnUi |
bool |
false |
Screenshot on UI and lifecycle events |
enableRageClickScreenshot |
bool |
false |
Screenshot subset for rage-click detection |
requestBlacklist |
List<String>? |
β | Wildcard URL blacklist for HTTP capture |
requestBodyWhitelist |
List<RequestBodyConfig>? |
β | URLs to capture request/response body on errors |
requestHeadersWhitelist |
List<RequestHeadersConfig>? |
β | URLs to capture whitelisted headers on errors |
rageClick |
RageClickConfig? |
β | Rage click configuration |
rateLimits |
RateLimits? |
β | Event type rate limits |
enableCrashes |
bool? |
β | Toggle crash events |
enableLifeCycleLog |
bool? |
β | Toggle lifecycle events |
enableRequestLog |
bool? |
β | Toggle request events |
enableTagger |
bool? |
β | Toggle tag events |
enablePerformance |
bool? |
β | Toggle performance events |
enableUI |
bool? |
β | Toggle UI events |
Rate Limit Configuration #
You can control the rate of captured events. Configure per-event-type limits inside ObslyConfig.rateLimits.
Event types:
- base, request, tag, console, ui, metric, error, performance, navigation
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
interval |
int |
1000 |
Interval in milliseconds for rate limiting |
trailing |
bool |
false |
Send trailing events after the rate limit period |
bucketSize |
int |
10 |
Max events to process within an interval (request: 20) |
emptyBucketDelay |
int |
1000 |
Delay in milliseconds before emptying the bucket |
rejectWhenBucketFull |
bool |
false |
If true, reject when full (console, error: true) |
Example:
final params = InitParameters(
ObslyKey: 'KEY',
instanceURL: 'https://api.url',
config: ObslyConfig(
rateLimits: const RateLimits(
error: RateLimitConfig(
interval: 2000,
bucketSize: 15,
trailing: true,
emptyBucketDelay: 2000,
rejectWhenBucketFull: false,
),
ui: RateLimitConfig(
bucketSize: 20,
emptyBucketDelay: 2000,
),
),
),
);
Request Headers Config #
Control which headers are captured for requests within a status range and URL pattern.
| Parameter | Type | Example | Description |
|---|---|---|---|
url |
String |
https://api.example.com/sensitive/* |
URL to capture headers (supports wildcards) |
fromStatus |
int |
400 |
Min HTTP status (inclusive) |
toStatus |
int |
599 |
Max HTTP status (inclusive) |
headers |
List<String> |
["content-type","x-request-id"] |
Specific headers to capture (supports wildcards) |
Example:
InitParameters(
ObslyKey: 'KEY',
instanceURL: 'https://api.url',
config: const ObslyConfig(
requestHeadersWhitelist: [
RequestHeadersConfig(
url: 'https://api.example.com/sensitive/*',
fromStatus: 400,
toStatus: 599,
headers: ['content-type', 'x-request-id'],
),
],
),
);
Request Body Config #
Capture request and/or response bodies on error ranges for matching URLs.
| Parameter | Type | Example | Description |
|---|---|---|---|
url |
String |
https://api.example.com/* |
URL to capture body (supports wildcards) |
fromStatus |
int |
400 |
Min HTTP status (inclusive) |
toStatus |
int |
599 |
Max HTTP status (inclusive) |
captureRequestBody |
bool |
true |
Capture request body |
captureResponseBody |
bool |
true |
Capture response body |
Example:
const config = ObslyConfig(
requestBodyWhitelist: [
RequestBodyConfig(
url: 'https://api.example.com/*',
fromStatus: 400,
toStatus: 599,
captureRequestBody: true,
captureResponseBody: true,
),
],
);
Wildcards #
Use wildcards in URL patterns and header names:
- URL:
https://example.com/*,https://*.example.com/*,*.example.com/* - Header:
*content*,content*,*type,x-*
Developer Methods #
All functions are available on ObslySDK.instance. For readability, performance and metrics are also exposed via namespaced getters Performance and Metrics.
Manage Sessions:
await ObslySDK.instance.closeCurrentSession();
await ObslySDK.instance.createNewSession('custom-session-id');
Client Identification:
await ObslySDK.instance.setUserID('user-123');
await ObslySDK.instance.setPersonID('person-456');
await ObslySDK.instance.setPassportID('passport-789');
await ObslySDK.instance.setContractID('contract-000');
Application Identification:
await ObslySDK.instance.setAppName('MyNewApp');
await ObslySDK.instance.setAppVersion('1.2.0');
Tag:
await ObslySDK.instance.addTag(
[Tag(key: 'user_tier', value: 'premium')],
'User Properties',
);
Screenshot:
await ObslySDK.instance.addScreenshot();
final base64Image = await ObslySDK.instance.getScreenshot();
Performance:
await ObslySDK.instance.Performance.startTransaction('DASHBOARD', 'Load Dashboard');
await ObslySDK.instance.Performance.startStep('Load Dashboard', 'DASHBOARD');
await ObslySDK.instance.Performance.finishStep('Load Dashboard', 'DASHBOARD');
await ObslySDK.instance.Performance.endTransaction('DASHBOARD');
Metrics:
ObslySDK.instance.Metrics.incCounter('CLICK_EVENT', 'FBL', 'OP', 'VIEW', 'OK');
ObslySDK.instance.Metrics.setGauge('CPU', 0.75, fbl: 'FBL', operation: 'OP', view: 'VIEW', state: 'OK');
ObslySDK.instance.Metrics.startHistogramTimer('FETCH', 'FBL', 'OP', 'VIEW');
ObslySDK.instance.Metrics.endHistogramTimer('FETCH', 'FBL', 'OP', 'VIEW', 'OK');
Application Context:
await ObslySDK.instance.setView('HOME');
await ObslySDK.instance.setOperation('ONBOARDING');
await ObslySDK.instance.setFunctionalBlock('AUTH');
SDK Control:
await ObslySDK.instance.pauseTracker();
await ObslySDK.instance.resumeTracker();
await ObslySDK.instance.setRequestsBlacklist(['https://*.sensitive.com/*']);
await ObslySDK.instance.setLogLevel('warn'); // 'null' | 'error' | 'warn' | 'log' | 'debug'
final session = ObslySDK.instance.getSessionInfo();
Miscellaneous:
await ObslySDK.instance.addFeedback('5', 'Great experience');
Migration Guide #
Migrating to Framework-Agnostic SDK #
From v0.x.x to v1.0.0 (Zero Breaking Changes)
Your existing MaterialApp code works unchanged:
Before (Old SDK - Still Works):
void main() {
ObslySDK.run(() {
runApp(
ObslySDK.wrapApp(
app: MaterialApp(...),
obslyKey: 'your-key',
instanceURL: 'https://api.obsly.io',
),
);
});
}
After (New SDK - Recommended):
void main() async {
ObslySDK.runWithAsyncErrorCapture(() async {
WidgetsFlutterBinding.ensureInitialized();
await ObslySDK.instance.init(
const InitParameters(
obslyKey: 'your-key',
instanceURL: 'https://api.obsly.io',
),
);
runApp(MyApp());
});
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ObslySDK.instance.wrapApp(
app: MaterialApp(...), // Works exactly the same
);
}
}
Benefits of Migration
- β CupertinoApp Support - Add iOS native support
- β Custom Architecture - Use any Flutter widget hierarchy
- β Better Error Handling - Advanced async error capture
- β Conflict Prevention - Safe with existing crash reporting
- β Navigation Flexibility - Ready for go_router, auto_route
Migration Checklist
-
Update Initialization (Optional but recommended):
// Replace ObslySDK.run() with: ObslySDK.runWithAsyncErrorCapture(() async { await ObslySDK.instance.init(params); runApp(MyApp()); }); -
Update App Wrapping (Optional but recommended):
// Replace ObslySDK.wrapApp() with parameters with: return ObslySDK.instance.wrapApp(app: yourApp); -
Enable Conflict Prevention (If using other crash tools):
ObslySDK.enableAsyncErrorCapture(preventConflicts: true); -
Test Different Architectures (Optional):
// Try CupertinoApp return ObslySDK.instance.wrapApp(app: CupertinoApp(...)); // Or custom architecture return ObslySDK.instance.wrapApp(app: CustomWidget(...));
Routing and Navigation Support #
Obsly Flutter SDK provides comprehensive support for both traditional Navigator and modern routing solutions like GoRouter.
π GoRouter Integration #
The SDK automatically detects and integrates with GoRouter-based applications. When using MaterialApp.router or CupertinoApp.router, the SDK:
- β Auto-detects GoRouter configuration and initializes navigation tracking
- β Captures navigation events with route names and transitions
- β Handles UI events correctly even with GoRouter's navigation architecture
- β Maintains zero configuration - works out of the box
Example: GoRouter Setup
import 'package:go_router/go_router.dart';
import 'package:obsly_flutter/obsly_sdk.dart';
final GoRouter router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfilePage(),
),
],
);
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ObslySDK.instance.wrapApp(
app: MaterialApp.router(
routerConfig: router,
title: 'My GoRouter App',
),
enableDebugTools: true, // Optional debug overlay
);
}
}
Manual GoRouter Initialization (Optional)
For advanced use cases, you can manually initialize GoRouter tracking:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await ObslySDK.instance.init(/* your config */);
// Optional: Manual GoRouter initialization
await ObslySDK.initializeGoRouter(router);
runApp(MyApp());
}
Traditional Navigator Support #
The SDK continues to provide full support for traditional Navigator-based applications:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ObslySDK.instance.wrapApp(
app: MaterialApp(
title: 'My Navigator App',
initialRoute: '/',
routes: {
'/': (context) => const HomePage(),
'/profile': (context) => const ProfilePage(),
},
),
);
}
}
Navigation Events Captured #
The SDK automatically captures the following navigation events:
- Route Changes: Push, pop, replace operations
- Route Names: Full path information and route identification
- Navigation Timing: Transition durations and timestamps
- Navigation Context: Previous and current route information
Custom Navigation Solutions #
For custom navigation implementations, you can integrate with Obsly's navigation tracking:
// Custom navigation provider
class CustomNavigationProvider extends NavigationProvider {
// Implement your custom navigation tracking
}
// Register your provider
ObslySDK.instance.wrapApp(
app: yourCustomApp,
customNavigationProvider: CustomNavigationProvider(),
);
Troubleshooting Navigation Issues #
If navigation events are not appearing:
- Verify GoRouter Version: Ensure you're using GoRouter 6.0.0 or later
- Check Debug Logs: Enable debug mode to see navigation initialization logs
- Manual Initialization: Try manual GoRouter initialization if auto-detection fails
// Enable debug mode for navigation troubleshooting
await ObslySDK.instance.init(
InitParameters(
debugMode: true, // Shows detailed navigation logs
logLevel: LogLevel.debug,
// ... other config
),
);
Debug Tools #
Enable the overlay by passing enableDebugTools: true to wrapApp. The overlay lets you inspect captured events and screenshots during development.
β οΈ Important: Production Usage #
Never enable debug tools in production builds. Debug tools show internal SDK messages and operations that should not be visible to end users.
Development:
ObslySDK.instance.wrapApp(
app: yourApp,
enableDebugTools: true, // β
OK for development
);
Production:
ObslySDK.instance.wrapApp(
app: yourApp,
enableDebugTools: false, // β Always false in production
);
Debug Tools Features #
When enabled, debug tools provide:
- Real-time event monitoring
- Session lifecycle notifications (including "new session created" messages)
- Screenshot capture and viewing
- Network request inspection
- Performance metrics
- Configuration management
- Rule execution monitoring
Important: Event Sending Behavior
When debug tools are enabled (enableDebugTools: true), automatic event sending is disabled by default. Events are captured and stored locally but are not sent to the Obsly server automatically. This allows developers to inspect events before sending them.
To send events to the server when debug tools are enabled:
- Use the "Send" button (π€) in the Events tab of the debug overlay
- Call
ObslySDK.instance.forceFlush()programmatically - The manual send will transmit all pending events to the server
This behavior ensures developers have full control over when events are sent during development and testing.
All debug notifications and overlays are intended for developers only and should never be seen by end users.
Requirements #
- Flutter >= 3.0.0
- Dart >= 3.0.0
Getting Help #
Questions or feedback? Email us at help@obsly.io.
License #
MIT License - see LICENSE file for details.
Support #
- π Documentation
- π Issues
- π¬ Discussions
Contributing #
Contributions are welcome! Please read our Contributing Guide for details.