Skip to main content

haste_health/
main.rs

1#[global_allocator]
2static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
3
4use std::{collections::HashMap, time::Duration};
5
6use clap::{Parser, Subcommand};
7use haste_config::{Config, ConfigType, get_config};
8use haste_fhir_model::r4::generated::terminology::IssueType;
9use haste_fhir_operation_error::OperationOutcomeError;
10use opentelemetry::KeyValue;
11use opentelemetry::trace::TracerProvider as _;
12use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
13use opentelemetry_otlp::WithExportConfig;
14use opentelemetry_otlp::{Protocol, WithHttpConfig};
15use opentelemetry_sdk::Resource;
16use opentelemetry_sdk::logs::SdkLoggerProvider;
17use opentelemetry_sdk::trace::SdkTracerProvider;
18use reqwest::Url;
19use tracing_subscriber::registry::LookupSpan;
20use tracing_subscriber::{EnvFilter, layer::SubscriberExt, registry::Registry};
21use tracing_tree::HierarchicalLayer;
22
23mod cli;
24mod commands;
25mod utilities;
26
27#[derive(Parser)]
28#[command(version, about, long_about = None)] // Read from `Cargo.toml`
29pub struct Cli {
30    #[command(subcommand)]
31    command: CliCommand,
32}
33
34#[derive(Subcommand)]
35enum CliCommand {
36    /// Evaluate a FHIRPath expression against a FHIR resource read from stdin.
37    #[command(name = "fhirpath")]
38    FHIRPath {
39        /// FHIRPath expression to evaluate
40        fhirpath: String,
41    },
42    /// Code generators (Rust FHIR types, operations, TestScripts) used to build this crate.
43    Generate {
44        #[command(subcommand)]
45        command: commands::codegen::CodeGen,
46    },
47    /// Patch externally provided (HL7) artifacts without editing the upstream files.
48    Artifacts {
49        #[command(subcommand)]
50        command: commands::artifacts::ArtifactCommands,
51    },
52    /// Publish what each subscription tier allows.
53    Subscription {
54        #[command(subcommand)]
55        command: commands::subscription::SubscriptionCommands,
56    },
57    /// Run the FHIR server.
58    Server {
59        #[command(subcommand)]
60        command: commands::server::ServerCommands,
61    },
62    /// Make FHIR REST API calls against the active profile's server.
63    Api {
64        #[command(subcommand)]
65        command: commands::api::ApiCommands,
66    },
67    /// Manage named server connection profiles used by other commands.
68    Profile {
69        #[command(subcommand)]
70        command: commands::profile::ProfileCommands,
71    },
72    /// Log in as a human user via the browser (authorization_code + PKCE flow).
73    Login {
74        /// Print the authorization URL instead of opening a browser.
75        #[arg(long)]
76        no_browser: bool,
77    },
78    /// Run the background workers (search indexing and async jobs), or only the one named.
79    Worker {
80        #[command(subcommand)]
81        command: Option<commands::worker::WorkerCommands>,
82    },
83    /// Run FHIR TestScript resources against the active profile's server.
84    Testscript {
85        #[command(subcommand)]
86        command: commands::testscript::TestScriptCommands,
87    },
88    /// Server-side administrative operations (tenants, users, clients, migrations).
89    Admin {
90        #[command(subcommand)]
91        command: commands::admin::AdminCommands,
92    },
93    /// Bridge HL7v2 messages to and from the FHIR server.
94    Hl7v2 {
95        #[command(subcommand)]
96        command: commands::hl7v2::HL7v2Commands,
97    },
98    /// Install FHIR packages (implementation guides) into the active
99    /// profile's project.
100    Package {
101        #[command(subcommand)]
102        command: commands::package::PackageCommands,
103    },
104    /// Generate Markdown documentation for this CLI's commands.
105    Doc {
106        /// Output markdown file path
107        #[arg(short, long)]
108        output: String,
109    },
110}
111
112enum CliEnvironmentVariables {
113    SentryDSN,
114    OTELEndpoint,
115    OTELHeaders,
116    LogType,
117    /// `true` captures a backtrace for every error that answers 5xx.
118    ErrorBacktraces,
119}
120
121impl From<CliEnvironmentVariables> for String {
122    fn from(value: CliEnvironmentVariables) -> Self {
123        match value {
124            CliEnvironmentVariables::SentryDSN => "SENTRY_DSN".to_string(),
125            CliEnvironmentVariables::OTELEndpoint => "OTEL_ENDPOINT".to_string(),
126            CliEnvironmentVariables::OTELHeaders => "OTEL_HEADERS".to_string(),
127            CliEnvironmentVariables::LogType => "LOG_TYPE".to_string(),
128            CliEnvironmentVariables::ErrorBacktraces => "ERROR_BACKTRACES".to_string(),
129        }
130    }
131}
132
133struct OtelGuard {
134    _tracer_provider: SdkTracerProvider,
135    _logger_provider: SdkLoggerProvider,
136}
137
138fn otel_guard(config: &dyn Config<CliEnvironmentVariables>) -> Option<OtelGuard> {
139    let endpoint = config.get(CliEnvironmentVariables::OTELEndpoint).ok()?;
140    let headers_str = config
141        .get(CliEnvironmentVariables::OTELHeaders)
142        .unwrap_or_default();
143    let headers = headers_str
144        .split(',')
145        .filter_map(|pair| {
146            let mut parts = pair.splitn(2, '=');
147            if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
148                Some((key.trim().to_string(), value.trim().to_string()))
149            } else {
150                None
151            }
152        })
153        .collect::<HashMap<String, String>>();
154
155    let root_otel_endpoint = Url::parse(&endpoint).expect("Invalid OTLP endpoint URL");
156
157    // See https://opentelemetry.io/docs/specs/otlp/#otlphttp-request
158    // v1/traces for spans, v1/logs for logs
159    let mut trace_endpoint = root_otel_endpoint.clone();
160    trace_endpoint
161        .path_segments_mut()
162        .expect("OTEL endpoint cannot be a base URL")
163        .extend(&["v1", "traces"]);
164
165    let mut log_endpoint = root_otel_endpoint.clone();
166    log_endpoint
167        .path_segments_mut()
168        .expect("OTEL endpoint cannot be a base URL")
169        .extend(&["v1", "logs"]);
170
171    let oltp_span_exporter = opentelemetry_otlp::SpanExporter::builder()
172        .with_http()
173        .with_protocol(Protocol::HttpBinary)
174        .with_endpoint(trace_endpoint.as_str())
175        .with_headers(headers.clone())
176        .with_timeout(Duration::from_secs(5))
177        .build()
178        .expect("Failed to create OpenTelemetry span exporter");
179
180    let oltp_log_exporter = opentelemetry_otlp::LogExporter::builder()
181        .with_http()
182        .with_protocol(Protocol::HttpBinary)
183        .with_endpoint(log_endpoint.as_str())
184        .with_headers(headers)
185        .with_timeout(Duration::from_secs(5))
186        .build()
187        .expect("Failed to create OpenTelemetry log exporter");
188
189    let resource = Resource::builder()
190        .with_attribute(KeyValue::new("service.name", "haste-health"))
191        .build();
192
193    let tracer_provider = SdkTracerProvider::builder()
194        .with_resource(resource.clone())
195        .with_batch_exporter(oltp_span_exporter)
196        .build();
197
198    let logger_provider = SdkLoggerProvider::builder()
199        .with_resource(resource)
200        .with_batch_exporter(oltp_log_exporter)
201        .build();
202
203    opentelemetry::global::set_tracer_provider(tracer_provider.clone());
204
205    Some(OtelGuard {
206        _tracer_provider: tracer_provider,
207        _logger_provider: logger_provider,
208    })
209}
210
211fn inject_otel_subscriber<S>(
212    subscriber: S,
213    config: &dyn Config<CliEnvironmentVariables>,
214) -> Option<OtelGuard>
215where
216    S: tracing::Subscriber + Send + Sync + 'static,
217    for<'span> S: LookupSpan<'span>,
218{
219    if let Some(guard) = otel_guard(config) {
220        let tracer = guard._tracer_provider.tracer("haste-health");
221        let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
222        let otel_logs = OpenTelemetryTracingBridge::new(&guard._logger_provider);
223
224        tracing::subscriber::set_global_default(subscriber.with(telemetry).with(otel_logs))
225            .unwrap();
226
227        Some(guard)
228    } else {
229        tracing::subscriber::set_global_default(subscriber).unwrap();
230        None
231    }
232}
233
234fn setup_tracing(
235    config: &dyn Config<CliEnvironmentVariables>,
236) -> Result<Option<OtelGuard>, OperationOutcomeError> {
237    let log_type = config
238        .get(CliEnvironmentVariables::LogType)
239        .unwrap_or("JSON".to_string());
240
241    // Error-level events (a failed request, see the server's error middleware)
242    // become Sentry events; nothing else is sent, so the layer costs nothing
243    // on the happy path.
244    let sentry_layer = sentry::integrations::tracing::layer().event_filter(|metadata| {
245        if *metadata.level() == tracing::Level::ERROR {
246            sentry::integrations::tracing::EventFilter::Event
247        } else {
248            sentry::integrations::tracing::EventFilter::Ignore
249        }
250    });
251
252    let subscriber = Registry::default()
253        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
254        .with(sentry_layer);
255
256    match log_type.as_str() {
257        "TREE" => Ok(inject_otel_subscriber(
258            subscriber.with(HierarchicalLayer::new(2)),
259            config,
260        )),
261        "JSON" => Ok(inject_otel_subscriber(
262            subscriber.with(tracing_subscriber::fmt::Layer::default().json()),
263            config,
264        )),
265        _ => Err(OperationOutcomeError::fatal(
266            IssueType::invalid(),
267            "Invalid log type specified in environment variable LOG_TYPE. Supported values are 'TREE' and 'JSON'.".to_string(),
268        )),
269    }
270}
271
272fn main() -> Result<(), OperationOutcomeError> {
273    let config = get_config(ConfigType::Environment);
274
275    let cli = Cli::parse();
276    let cli_state = cli::state::CLI_STATE.clone();
277
278    OperationOutcomeError::capture_backtraces(
279        config
280            .get(CliEnvironmentVariables::ErrorBacktraces)
281            .is_ok_and(|value| matches!(value.as_str(), "true" | "1")),
282    );
283
284    let sentry_location = config.get(CliEnvironmentVariables::SentryDSN);
285
286    let _guard = sentry::init((
287        sentry_location.unwrap_or_default(),
288        sentry::ClientOptions {
289            release: sentry::release_name!(),
290            // Capture user IPs and potentially sensitive headers when using HTTP server integrations
291            // see https://docs.sentry.io/platforms/rust/data-management/data-collected for more info
292            send_default_pii: true,
293            ..Default::default()
294        },
295    ));
296
297    tokio::runtime::Builder::new_multi_thread()
298        .enable_all()
299        // 8MB stack size
300        .thread_stack_size(1024 * 8000)
301        .build()
302        .unwrap()
303        .block_on(async {
304            let _otel_provider = setup_tracing(config.as_ref())?;
305            commands::run(cli_state, &cli.command).await
306        })
307}